Merge release/0-32-0 into main: the 0.32 features meet the Spanish strings
32 conflict hunks across 21 files. The two sides are the 0.32 feature branch (cards, events, games, Today, the small-web directory, hello links, story take-downs, stickers) and main (the Spanish catalog, attachment padding, the stage 2-B request gate, bridge offers). Neither side is the answer anywhere the conflict is about what the app does. What the resolutions keep, and why: Info.plist and project.yml: BOTH URL types. One claims `flamenet` for contact links, the other `flamenetmessenger` for hello links. Taking either side alone would have stopped the other kind of link opening the app at all. CardScanner: git had already merged the controller to the union, with both the injected `accepts` predicate and the new `onRefused` callback. The call sites now match it. `accepts` is the only gate, because hard-coding `ScannedCode` there would have refused every code the Devices screen scans. The address sheet's default is `ScannedCode.card`, never `ContactCard.decode`, so an older card's `?u=` username is still never trusted (DESIGN 32). E2EInbox: `seedAccountStateFromDeviceIfNeeded()` moved to the factory rather than into the release branch's new `connect()`. It reads this device's own store and never the relay, and the offline launch path passes `connect: false`, which would otherwise show every existing conversation as a request until the relay answered. `deliverRemote` keeps both gates: main's `remoteMailShows` and the release branch's control-traffic cases. E2EEnvelopeRoute: bridgeOffer, rsvp and unpost all three, in the enum, in `all`, and in the shape test. MainTabView and PillTabBar: the extracted `PillTabItem` and `tabItems()` are kept, with the `identifier` field put back and every title through `String(localized:)`. `title` is a plain String, so the release branch's bare literals would have left every tab English in every language. ParentalControlsView: the release branch's fuller footer, written as ONE literal. `Text` localizes a literal and not a `+` chain, so the concatenated form would have taken that footer out of the catalog. Eleven more places take the union with the localized string kept: the attachment and sticker previews in E2EInbox, GroupsSection, BuddyListView, ChatView, ChatViewModel and StoriesFeedView. Also here: 35 spec references renumbered. `a3b05c3` in flamenet-e2e moved anonymous reads and contact links to §8.13 and §8.14 to make room for the push token and the Today file at §8.11 and §8.12, so every reference in this tree to "8.11, anonymous reads" or "8.12, contact links" now pointed at the wrong section. The Today and push references were already right and are untouched. .e2e-ref is pinned to 4b4042f72880758ee2dea7713f7a2cb63fa12f73, the unified spec, which is also what Android pins. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@@ -1 +1 @@
|
||||
4b850007a748be7d0716c0da3455a424cf58ddcc
|
||||
4b4042f72880758ee2dea7713f7a2cb63fa12f73
|
||||
|
||||
@@ -100,7 +100,8 @@ unit-tests:
|
||||
- git -C ci-vectors checkout -q "$FLAMENET_E2E_PIN"
|
||||
- |
|
||||
for f in protocol-v1 pqx3dh-v2 account-kdf-v1 backup-v1 backup-payload-v1 content-v1 \
|
||||
poll-tally-v1 padding-v1 sync-merge-v1 handle-cert-v1 links-v1 bridge-offer-v1; do
|
||||
poll-tally-v1 padding-v1 sync-merge-v1 handle-cert-v1 links-v1 bridge-offer-v1 \
|
||||
content-kinds-v1 today-v1 hello-link-v1; do
|
||||
if ! diff -q "ci-vectors/test/vectors/$f.json" "Tests/Vectors/$f.json" >/dev/null; then
|
||||
echo "GATE: Tests/Vectors/$f.json differs from flamenet-e2e@$FLAMENET_E2E_PIN."
|
||||
echo "GATE: the vectors moved. Read what changed, then refresh the copy:"
|
||||
@@ -177,6 +178,25 @@ unit-tests:
|
||||
expire_in: 1 week
|
||||
timeout: 30m
|
||||
|
||||
# The rules engines for the games in a chat (§6.33), a local SwiftPM package
|
||||
# with its own tests and the vectors in games-v1.json, which Android's `:games`
|
||||
# module runs too. `swift test` needs no simulator; a cold run here took 12
|
||||
# seconds. Its own job, so a rules failure reads as one.
|
||||
games-engine:
|
||||
stage: test
|
||||
tags: [mac-local]
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||
variables:
|
||||
GIT_DEPTH: "1"
|
||||
script:
|
||||
- export DEVELOPER_DIR="$(xcode-select -p)"
|
||||
# Keep the build output out of Spotlight, as in `unit-tests`.
|
||||
- mkdir -p Packages/FlamenetGames/.build && touch Packages/FlamenetGames/.build/.metadata_never_index
|
||||
- swift test --package-path Packages/FlamenetGames
|
||||
timeout: 15m
|
||||
|
||||
# The 83 tests in `UITests/` that nothing ran.
|
||||
#
|
||||
# `unit-tests` above passes `-only-testing:FlamenetMessengerTests`, so the UI
|
||||
@@ -324,7 +344,8 @@ vectors-head:
|
||||
echo "at HEAD: $(git -C ci-vectors-head rev-parse HEAD)"
|
||||
behind=0
|
||||
for f in protocol-v1 pqx3dh-v2 account-kdf-v1 backup-v1 backup-payload-v1 content-v1 \
|
||||
poll-tally-v1 padding-v1 sync-merge-v1 handle-cert-v1 links-v1 bridge-offer-v1; do
|
||||
poll-tally-v1 padding-v1 sync-merge-v1 handle-cert-v1 links-v1 bridge-offer-v1 \
|
||||
content-kinds-v1 today-v1 hello-link-v1; do
|
||||
if ! diff -q "ci-vectors-head/test/vectors/$f.json" "Tests/Vectors/$f.json" >/dev/null; then
|
||||
echo "ADVISORY: $f.json has moved at HEAD:"
|
||||
diff "ci-vectors-head/test/vectors/$f.json" "Tests/Vectors/$f.json" | head -30
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
FlameNet sticker pack `flamenet.retro`: 24 original drawings in `svg/`, rendered to the 288x288 transparent PNGs the app ships in `Resources/stickers/flamenet.retro/`, in the picker order of `Resources/stickers/flamenet.retro.json`. The Android app ships the same PNG bytes.
|
||||
|
||||
To re-render after editing an SVG, run this from this folder with Node 22 or later: `MANIFEST=../../Resources/stickers/flamenet.retro.json OUT=../../Resources/stickers/flamenet.retro PW_DIR=<folder with node_modules/playwright-core> CHROME=<Chromium binary> node render.mjs`. It also writes two contact sheets here, which are not shipped.
|
||||
@@ -0,0 +1,191 @@
|
||||
// Renders the FlameNet sticker pack: svg/<id>.svg -> flamenet.retro/<id>.png
|
||||
// (288x288, transparent, 8-bit palette PNG) and the two contact sheets.
|
||||
// Picker order and names come from flamenet.retro.json.
|
||||
//
|
||||
// Needs Node 22 or later and playwright-core with a Chromium build:
|
||||
// PW_DIR=<folder whose node_modules has playwright-core> \
|
||||
// CHROME=<chromium or chrome-headless-shell binary> node render.mjs
|
||||
// Without PW_DIR it imports playwright-core from here; without CHROME it
|
||||
// uses the browser Playwright installed. MANIFEST and OUT point it at other
|
||||
// copies of flamenet.retro.json and the PNG folder; both default to here.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import zlib from "node:zlib";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SIZE = 288;
|
||||
const SHEET = 96;
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require(process.env.PW_DIR
|
||||
? require.resolve("playwright-core", { paths: [process.env.PW_DIR] })
|
||||
: "playwright-core");
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(process.env.MANIFEST || path.join(HERE, "flamenet.retro.json"), "utf8"));
|
||||
|
||||
// The sources have to render the same everywhere: no ids, no references,
|
||||
// no fonts, one canvas size.
|
||||
function check(id, svg) {
|
||||
const bad = [
|
||||
[/\sid\s*=/, "an id"],
|
||||
[/href|url\(/, "a reference"],
|
||||
[/<(text|tspan|image|style|script|foreignObject)\b|font-/, "text, fonts or embedded content"],
|
||||
].find(([re]) => re.test(svg));
|
||||
if (bad) throw new Error(`${id}.svg has ${bad[1]}`);
|
||||
if (!/^<svg [^>]*viewBox="0 0 200 200"/.test(svg)) throw new Error(`${id}.svg needs viewBox="0 0 200 200"`);
|
||||
}
|
||||
|
||||
// 8-bit palette PNG.
|
||||
|
||||
// Colours compared premultiplied, so faint edge pixels count for little.
|
||||
const unpack = k => [k >>> 24, (k >>> 16) & 255, (k >>> 8) & 255, k & 255];
|
||||
const pm = k => { const [r, g, b, a] = unpack(k); return [r * a / 255, g * a / 255, b * a / 255, a]; };
|
||||
const pack = (r, g, b, a) => ((r << 24) | (g << 16) | (b << 8) | a) >>> 0;
|
||||
|
||||
// Splits the leftover edge colours into n boxes, each time cutting the box
|
||||
// with the largest squared error along its widest channel.
|
||||
function medianCut(entries, n) {
|
||||
const sse = box => {
|
||||
let w = 0; const s = [0, 0, 0, 0], q = [0, 0, 0, 0];
|
||||
for (const p of box) { w += p.c; for (let d = 0; d < 4; d++) { s[d] += p.v[d] * p.c; q[d] += p.v[d] * p.v[d] * p.c; } }
|
||||
const per = q.map((x, d) => x - s[d] * s[d] / w);
|
||||
return { total: per.reduce((a, b) => a + b, 0), dim: per.indexOf(Math.max(...per)), w };
|
||||
};
|
||||
let boxes = [entries.map(([k, c]) => ({ k, c, v: pm(k) }))].filter(b => b.length);
|
||||
while (boxes.length && boxes.length < n) {
|
||||
let best = -1, bestErr = 0, dim = 0;
|
||||
boxes.forEach((b, i) => {
|
||||
if (b.length < 2) return;
|
||||
const e = sse(b);
|
||||
if (e.total > bestErr) { bestErr = e.total; best = i; dim = e.dim; }
|
||||
});
|
||||
if (best < 0) break;
|
||||
const box = boxes[best].slice().sort((a, b) => a.v[dim] - b.v[dim] || a.k - b.k);
|
||||
const half = box.reduce((a, p) => a + p.c, 0) / 2;
|
||||
let acc = 0, cut = 1;
|
||||
for (let i = 0; i < box.length - 1; i++) { acc += box[i].c; if (acc >= half) { cut = i + 1; break; } }
|
||||
boxes.splice(best, 1, box.slice(0, cut), box.slice(cut));
|
||||
}
|
||||
return boxes.map(box => {
|
||||
let w = 0; const s = [0, 0, 0, 0];
|
||||
for (const p of box) { w += p.c; for (let d = 0; d < 4; d++) s[d] += p.v[d] * p.c; }
|
||||
const a = Math.round(s[3] / w);
|
||||
if (a === 0) return 0;
|
||||
const ch = d => Math.max(0, Math.min(255, Math.round((s[d] / w) * 255 / (s[3] / w))));
|
||||
return pack(ch(0), ch(1), ch(2), a);
|
||||
});
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const head = Buffer.alloc(4); head.writeUInt32BE(data.length);
|
||||
const body = Buffer.concat([Buffer.from(type, "latin1"), data]);
|
||||
const crc = Buffer.alloc(4); crc.writeUInt32BE(zlib.crc32(body) >>> 0);
|
||||
return Buffer.concat([head, body, crc]);
|
||||
}
|
||||
|
||||
function encodePalettePNG(rgba, w, h) {
|
||||
const n = w * h, keys = new Uint32Array(n), hist = new Map();
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = rgba[4 * i + 3];
|
||||
const k = a === 0 ? 0 : pack(rgba[4 * i], rgba[4 * i + 1], rgba[4 * i + 2], a);
|
||||
keys[i] = k;
|
||||
hist.set(k, (hist.get(k) || 0) + 1);
|
||||
}
|
||||
const byCount = [...hist.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]);
|
||||
let palette;
|
||||
if (byCount.length <= 256) {
|
||||
palette = byCount.map(e => e[0]);
|
||||
} else {
|
||||
// Flat fills keep their exact colour; only edge blends are merged.
|
||||
const fixed = byCount.filter(e => e[1] >= 24).slice(0, 128).map(e => e[0]);
|
||||
const taken = new Set(fixed);
|
||||
palette = [...new Set(fixed.concat(medianCut(byCount.filter(e => !taken.has(e[0])), 256 - fixed.length)))];
|
||||
}
|
||||
// Translucent entries first, so tRNS can stop at the last one.
|
||||
palette.sort((a, b) => ((a & 255) === 255) - ((b & 255) === 255) || (a & 255) - (b & 255) || a - b);
|
||||
const pv = palette.map(pm);
|
||||
const index = new Map();
|
||||
for (const k of hist.keys()) {
|
||||
const v = pm(k);
|
||||
let best = 0, bd = Infinity;
|
||||
for (let j = 0; j < pv.length; j++) {
|
||||
const d = (v[0] - pv[j][0]) ** 2 + (v[1] - pv[j][1]) ** 2 + (v[2] - pv[j][2]) ** 2 + 2 * (v[3] - pv[j][3]) ** 2;
|
||||
if (d < bd) { bd = d; best = j; }
|
||||
}
|
||||
index.set(k, best);
|
||||
}
|
||||
const raw = Buffer.alloc(h * (w + 1));
|
||||
for (let y = 0; y < h; y++) {
|
||||
raw[y * (w + 1)] = 0;
|
||||
for (let x = 0; x < w; x++) raw[y * (w + 1) + 1 + x] = index.get(keys[y * w + x]);
|
||||
}
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4);
|
||||
ihdr[8] = 8; ihdr[9] = 3;
|
||||
const plte = Buffer.alloc(palette.length * 3), alpha = [];
|
||||
palette.forEach((k, j) => {
|
||||
const [r, g, b, a] = unpack(k);
|
||||
plte[3 * j] = r; plte[3 * j + 1] = g; plte[3 * j + 2] = b;
|
||||
if (a < 255) alpha.push(a);
|
||||
});
|
||||
return Buffer.concat([
|
||||
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("PLTE", plte),
|
||||
...(alpha.length ? [chunk("tRNS", Buffer.from(alpha))] : []),
|
||||
chunk("IDAT", zlib.deflateSync(raw, { level: 9, memLevel: 9 })),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
// Render.
|
||||
|
||||
const browser = await chromium.launch(process.env.CHROME ? { executablePath: process.env.CHROME } : {});
|
||||
const page = await browser.newPage({ viewport: { width: SIZE, height: SIZE }, deviceScaleFactor: 1 });
|
||||
const outDir = process.env.OUT || path.join(HERE, "flamenet.retro");
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const pngs = {};
|
||||
for (const s of manifest.stickers) {
|
||||
const svg = fs.readFileSync(path.join(HERE, "svg", `${s.id}.svg`), "utf8").trim();
|
||||
check(s.id, svg);
|
||||
if (s.image !== `${s.id}.png`) throw new Error(`${s.id}: image should be ${s.id}.png`);
|
||||
const sized = svg.replace("<svg ", `<svg width="${SIZE}" height="${SIZE}" `);
|
||||
const b64 = await page.evaluate(async ({ sized, size }) => {
|
||||
const img = new Image();
|
||||
img.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(sized);
|
||||
await img.decode();
|
||||
const canvas = new OffscreenCanvas(size, size);
|
||||
const g = canvas.getContext("2d");
|
||||
g.drawImage(img, 0, 0, size, size);
|
||||
const d = g.getImageData(0, 0, size, size).data;
|
||||
let bin = "";
|
||||
for (let i = 0; i < d.length; i += 0x8000) bin += String.fromCharCode.apply(null, d.subarray(i, i + 0x8000));
|
||||
return btoa(bin);
|
||||
}, { sized, size: SIZE });
|
||||
const png = encodePalettePNG(Buffer.from(b64, "base64"), SIZE, SIZE);
|
||||
fs.writeFileSync(path.join(outDir, s.image), png);
|
||||
pngs[s.id] = png;
|
||||
console.log(`${s.image.padEnd(22)} ${String(png.length).padStart(6)} bytes`);
|
||||
}
|
||||
|
||||
// Contact sheets, 96 px on white and on black.
|
||||
|
||||
const esc = t => t.replace(/[&<>"]/g, c => `&#${c.charCodeAt(0)};`);
|
||||
for (const [name, bg, fg] of [["sheet-light.png", "#ffffff", "#333333"], ["sheet-dark.png", "#000000", "#e8e8e8"]]) {
|
||||
const cells = manifest.stickers.map(s =>
|
||||
`<figure><img src="data:image/png;base64,${pngs[s.id].toString("base64")}" width="${SHEET}" height="${SHEET}" alt=""><figcaption>${esc(s.name)}</figcaption></figure>`).join("");
|
||||
await page.setViewportSize({ width: 6 * 124 + 24, height: 200 });
|
||||
await page.setContent(`<!doctype html><html><head><style>
|
||||
body { margin: 0; padding: 12px; background: ${bg}; color: ${fg}; font: 12px -apple-system, Helvetica, Arial, sans-serif; }
|
||||
main { display: grid; grid-template-columns: repeat(6, 124px); }
|
||||
figure { margin: 0; padding: 8px 0 10px; text-align: center; }
|
||||
img { display: block; margin: 0 auto 4px; }
|
||||
</style></head><body><main>${cells}</main></body></html>`);
|
||||
await page.screenshot({ path: path.join(HERE, name), fullPage: true });
|
||||
console.log(`${name} written`);
|
||||
}
|
||||
await browser.close();
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g transform="rotate(-8 100 100)">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
|
||||
<path d="M100 14 C136 14 156 42 156 76 C156 110 128 138 100 140 C72 138 44 110 44 76 C44 42 64 14 100 14 Z"/>
|
||||
<path d="M92 150 L100 139 L108 150 Z"/>
|
||||
<path d="M100 150 C91 160 108 167 100 175 C94 181 99 183 97 186" fill="none"/>
|
||||
</g>
|
||||
<path d="M100 150 C91 160 108 167 100 175 C94 181 99 183 97 186" fill="none" stroke="#333" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<path d="M100 14 C136 14 156 42 156 76 C156 110 128 138 100 140 C72 138 44 110 44 76 C44 42 64 14 100 14 Z" fill="#FF3B3B" stroke="#333" stroke-width="2"/>
|
||||
<path d="M92 150 L100 139 L108 150 Z" fill="#FF3B3B" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
|
||||
<ellipse cx="76" cy="50" rx="10" ry="19" fill="#fff" opacity=".55" transform="rotate(20 76 50)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 938 B |
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g transform="rotate(-6 100 108)">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
|
||||
<circle cx="100" cy="24" r="7"/>
|
||||
<path d="M100 26 L44 66 M100 26 L156 66" fill="none" stroke-width="17"/>
|
||||
<rect x="24" y="64" width="152" height="88" rx="10"/>
|
||||
</g>
|
||||
<path d="M100 26 L44 66 M100 26 L156 66" fill="none" stroke="#6B3E1F" stroke-width="3" stroke-linecap="round"/>
|
||||
<circle cx="100" cy="24" r="7" fill="#B8B8B8" stroke="#333" stroke-width="2"/>
|
||||
<rect x="24" y="64" width="152" height="88" rx="10" fill="#FFCC00" stroke="#333" stroke-width="2"/>
|
||||
<rect x="31" y="71" width="138" height="74" rx="6" fill="none" stroke="#E6A800" stroke-width="2"/>
|
||||
<circle cx="44" cy="68" r="3" fill="#6B3E1F"/>
|
||||
<circle cx="156" cy="68" r="3" fill="#6B3E1F"/>
|
||||
<g fill="#003399" fill-rule="evenodd">
|
||||
<path transform="translate(46.5 0)" d="M0 85 H19 C26 85 29 90 29 96.5 C29 101 27 104 23.5 105.5 C28 107 31 111 31 117.5 C31 125.5 26 131 18 131 H0 Z M11 94 H16 C18.5 94 19 95.5 19 97 C19 98.5 18.5 100 16 100 H11 Z M11 111 H18 C20.5 111 21 113 21 115.5 C21 118 20.5 121 18 121 H11 Z"/>
|
||||
<path transform="translate(84.5 0)" d="M0 131 V85 H18 C26 85 30 90.5 30 98 C30 104 27 108 22.5 110 L31 131 H19.5 L12 112.5 H11 V131 Z M11 94 H17 C19.5 94 20 96 20 98.5 C20 101 19.5 103.5 17 103.5 H11 Z"/>
|
||||
<path transform="translate(122.5 0)" d="M0 85 H19 C26 85 29 90 29 96.5 C29 101 27 104 23.5 105.5 C28 107 31 111 31 117.5 C31 125.5 26 131 18 131 H0 Z M11 94 H16 C18.5 94 19 95.5 19 97 C19 98.5 18.5 100 16 100 H11 Z M11 111 H18 C20.5 111 21 113 21 115.5 C21 118 20.5 121 18 121 H11 Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,43 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
|
||||
<ellipse cx="100" cy="174" rx="80" ry="12"/>
|
||||
<rect x="26" y="92" width="148" height="80" rx="10"/>
|
||||
<rect x="57" y="56" width="10" height="40"/>
|
||||
<rect x="95" y="56" width="10" height="40"/>
|
||||
<rect x="133" y="56" width="10" height="40"/>
|
||||
<path d="M62 30 C70 40 70 48 62 54 C54 48 54 40 62 30 Z"/>
|
||||
<path d="M100 30 C108 40 108 48 100 54 C92 48 92 40 100 30 Z"/>
|
||||
<path d="M138 30 C146 40 146 48 138 54 C130 48 130 40 138 30 Z"/>
|
||||
</g>
|
||||
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<ellipse cx="100" cy="174" rx="80" ry="12" fill="#CCE6FF"/>
|
||||
<path d="M30 106 H170 V162 Q170 172 160 172 H40 Q30 172 30 162 Z" fill="#FF99BB"/>
|
||||
<rect x="30" y="146" width="140" height="8" fill="#FFFDF5" stroke-width="1.5"/>
|
||||
<path d="M30 106 H170 V162 Q170 172 160 172 H40 Q30 172 30 162 Z" fill="none"/>
|
||||
<path d="M26 102 Q26 92 36 92 H164 Q174 92 174 102 V112 C174 120 160 120 160 112 C160 108 152 108 150 112 V124 C150 132 136 132 136 124 V114 C136 108 124 108 120 114 C118 120 108 120 106 114 C104 108 96 108 94 114 V128 C94 136 80 136 80 128 V114 C80 108 70 108 66 114 C64 120 54 120 52 114 C50 108 42 108 40 114 V120 C40 128 26 128 26 120 Z" fill="#FFFDF5"/>
|
||||
<rect x="57" y="62" width="10" height="30" fill="#3366FF"/>
|
||||
<rect x="95" y="62" width="10" height="30" fill="#FFCC00"/>
|
||||
<rect x="133" y="62" width="10" height="30" fill="#33AA33"/>
|
||||
</g>
|
||||
<g stroke="#333" stroke-width="2.5" stroke-linecap="round">
|
||||
<path d="M62 62 V55 M100 62 V55 M138 62 V55"/>
|
||||
</g>
|
||||
<g fill="#FF9900" stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<path d="M62 30 C70 40 70 48 62 54 C54 48 54 40 62 30 Z"/>
|
||||
<path d="M100 30 C108 40 108 48 100 54 C92 48 92 40 100 30 Z"/>
|
||||
<path d="M138 30 C146 40 146 48 138 54 C130 48 130 40 138 30 Z"/>
|
||||
</g>
|
||||
<g fill="#FFCC00">
|
||||
<ellipse cx="62" cy="46" rx="3.5" ry="5"/>
|
||||
<ellipse cx="100" cy="46" rx="3.5" ry="5"/>
|
||||
<ellipse cx="138" cy="46" rx="3.5" ry="5"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="46" y="135" width="9" height="4" rx="2" fill="#3366FF" transform="rotate(30 50 137)"/>
|
||||
<rect x="112" y="136" width="9" height="4" rx="2" fill="#FFCC00" transform="rotate(-25 116 138)"/>
|
||||
<rect x="152" y="134" width="9" height="4" rx="2" fill="#FFFFFF" transform="rotate(40 156 136)"/>
|
||||
<rect x="60" y="160" width="9" height="4" rx="2" fill="#FFCC00" transform="rotate(-30 64 162)"/>
|
||||
<rect x="96" y="161" width="9" height="4" rx="2" fill="#3366FF" transform="rotate(15 100 163)"/>
|
||||
<rect x="136" y="160" width="9" height="4" rx="2" fill="#33AA33" transform="rotate(-35 140 162)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
|
||||
<path d="M100 12 C118 34 119 52 100 64 C81 52 82 34 100 12 Z"/>
|
||||
<path d="M100 60 V74"/>
|
||||
<rect x="64" y="72" width="72" height="100" rx="5"/>
|
||||
<ellipse cx="100" cy="176" rx="62" ry="14"/>
|
||||
</g>
|
||||
<path d="M100 60 V74" stroke="#333" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<path d="M100 12 C118 34 119 52 100 64 C81 52 82 34 100 12 Z" fill="#FF9900" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M100 30 C109 43 109 54 100 60 C91 54 91 43 100 30 Z" fill="#FFCC00"/>
|
||||
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<ellipse cx="100" cy="177" rx="62" ry="13" fill="#E6A800"/>
|
||||
<ellipse cx="100" cy="171" rx="62" ry="13" fill="#FFCC00"/>
|
||||
<rect x="64" y="72" width="72" height="100" rx="5" fill="#FFF6CC"/>
|
||||
<path d="M64 77 Q64 72 69 72 H131 Q136 72 136 77 V92 C136 98 127 98 127 92 C127 87 120 87 120 93 V104 C120 113 108 113 108 104 V93 C108 87 99 87 97 93 C95 99 86 99 86 93 C86 88 79 87 77 93 V98 C77 105 64 105 64 98 Z" fill="#FFFFFF"/>
|
||||
</g>
|
||||
<rect x="73" y="112" width="8" height="50" rx="4" fill="#fff" opacity=".8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
|
||||
<path d="M40 66 H140 V146 Q140 176 110 176 H70 Q40 176 40 146 Z"/>
|
||||
<ellipse cx="90" cy="66" rx="50" ry="10"/>
|
||||
<path d="M140 86 C178 84 180 138 140 136" fill="none" stroke-width="35"/>
|
||||
<path d="M70 46 C62 36 78 30 70 18 M92 44 C84 34 100 28 92 14 M114 46 C106 36 122 30 114 18" fill="none" stroke-width="19"/>
|
||||
</g>
|
||||
<g fill="none" stroke-linecap="round">
|
||||
<path d="M70 46 C62 36 78 30 70 18 M92 44 C84 34 100 28 92 14 M114 46 C106 36 122 30 114 18" stroke="#8C9BB0" stroke-width="5"/>
|
||||
<path d="M140 86 C178 84 180 138 140 136" stroke="#333" stroke-width="21"/>
|
||||
<path d="M140 86 C178 84 180 138 140 136" stroke="#CC0000" stroke-width="17"/>
|
||||
</g>
|
||||
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<path d="M40 66 H140 V146 Q140 176 110 176 H70 Q40 176 40 146 Z" fill="#CC0000"/>
|
||||
<ellipse cx="90" cy="66" rx="50" ry="10" fill="#CC0000"/>
|
||||
<ellipse cx="90" cy="67" rx="43" ry="6.5" fill="#6B3E1F"/>
|
||||
</g>
|
||||
<rect x="40" y="88" width="100" height="10" fill="#fff"/>
|
||||
<path d="M40 70 V146 Q40 176 70 176 H110 Q140 176 140 146 V70" fill="none" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M40 88 H140 M40 98 H140" stroke="#333" stroke-width="1.5"/>
|
||||
<rect x="50" y="106" width="8" height="52" rx="4" fill="#fff" opacity=".4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
|
||||
<rect x="24" y="20" width="152" height="126" rx="16"/>
|
||||
<path d="M80 144 H120 L126 160 H74 Z"/>
|
||||
<rect x="46" y="158" width="108" height="20" rx="9"/>
|
||||
</g>
|
||||
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<path d="M80 144 H120 L126 160 H74 Z" fill="#D6CFB6"/>
|
||||
<rect x="46" y="158" width="108" height="20" rx="9" fill="#E8E2CC"/>
|
||||
<rect x="24" y="20" width="152" height="126" rx="16" fill="#E8E2CC"/>
|
||||
<rect x="40" y="34" width="120" height="88" rx="14" fill="#003399"/>
|
||||
<rect x="50" y="129" width="18" height="8" rx="3" fill="#D6CFB6" stroke-width="1.5"/>
|
||||
<circle cx="148" cy="133" r="4.5" fill="#33AA33" stroke-width="1.5"/>
|
||||
</g>
|
||||
<rect x="50" y="42" width="28" height="6" rx="3" fill="#fff" opacity=".35"/>
|
||||
<g fill="#FFCC00">
|
||||
<rect x="73" y="56" width="12" height="21" rx="6"/>
|
||||
<rect x="115" y="56" width="12" height="21" rx="6"/>
|
||||
</g>
|
||||
<path d="M72 90 Q100 114 128 90" fill="none" stroke="#FFCC00" stroke-width="7" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,51 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
|
||||
<circle cx="46" cy="38" r="13"/>
|
||||
<circle cx="154" cy="38" r="13"/>
|
||||
<rect x="40" y="46" width="12" height="130"/>
|
||||
<rect x="148" y="46" width="12" height="130"/>
|
||||
<rect x="28" y="168" width="36" height="12" rx="3"/>
|
||||
<rect x="136" y="168" width="36" height="12" rx="3"/>
|
||||
<rect x="24" y="66" width="152" height="80"/>
|
||||
<path d="M46 20 V14 M30 26 L25 22 M62 26 L67 22 M154 20 V14 M138 26 L133 22 M170 26 L175 22" stroke-width="17" stroke-linecap="round"/>
|
||||
</g>
|
||||
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<rect x="40" y="50" width="12" height="122" fill="#E6E6E6"/>
|
||||
<rect x="148" y="50" width="12" height="122" fill="#E6E6E6"/>
|
||||
<rect x="28" y="168" width="36" height="12" rx="3" fill="#FF9900"/>
|
||||
<rect x="136" y="168" width="36" height="12" rx="3" fill="#FF9900"/>
|
||||
<rect x="24" y="66" width="152" height="32" fill="#FFCC00"/>
|
||||
<rect x="24" y="116" width="152" height="30" fill="#FFCC00"/>
|
||||
</g>
|
||||
<g fill="#333">
|
||||
<polygon points="24,88 24,72 30,66 46,66"/>
|
||||
<polygon points="46,98 30,98 62,66 78,66"/>
|
||||
<polygon points="78,98 62,98 94,66 110,66"/>
|
||||
<polygon points="110,98 94,98 126,66 142,66"/>
|
||||
<polygon points="142,98 126,98 158,66 174,66"/>
|
||||
<polygon points="158,98 176,80 176,96 174,98"/>
|
||||
<polygon points="24,138 24,122 30,116 46,116"/>
|
||||
<polygon points="48,146 32,146 62,116 78,116"/>
|
||||
<polygon points="80,146 64,146 94,116 110,116"/>
|
||||
<polygon points="112,146 96,146 126,116 142,116"/>
|
||||
<polygon points="144,146 128,146 158,116 174,116"/>
|
||||
<polygon points="160,146 176,130 176,146"/>
|
||||
</g>
|
||||
<g fill="none" stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<rect x="24" y="66" width="152" height="32"/>
|
||||
<rect x="24" y="116" width="152" height="30"/>
|
||||
</g>
|
||||
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<rect x="38" y="44" width="16" height="8" rx="2" fill="#555"/>
|
||||
<rect x="146" y="44" width="16" height="8" rx="2" fill="#555"/>
|
||||
<path d="M34 44 A12 12 0 0 1 58 44 Z" fill="#FF9900"/>
|
||||
<path d="M142 44 A12 12 0 0 1 166 44 Z" fill="#FF9900"/>
|
||||
</g>
|
||||
<g fill="#fff" opacity=".7">
|
||||
<ellipse cx="41" cy="37" rx="2.5" ry="4"/>
|
||||
<ellipse cx="149" cy="37" rx="2.5" ry="4"/>
|
||||
</g>
|
||||
<g stroke="#FF9900" stroke-width="3" stroke-linecap="round">
|
||||
<path d="M46 20 V14 M30 26 L25 22 M62 26 L67 22 M154 20 V14 M138 26 L133 22 M170 26 L175 22"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<path d="M100 188 C68 188 44 166 44 136 C44 110 48 86 52 62 C60 78 68 90 76 96 C74 68 88 40 116 12 C110 36 116 58 132 80 C140 72 150 64 156 54 C162 84 158 108 158 136 C158 166 132 188 100 188 Z" fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round"/>
|
||||
<path d="M100 188 C68 188 44 166 44 136 C44 110 48 86 52 62 C60 78 68 90 76 96 C74 68 88 40 116 12 C110 36 116 58 132 80 C140 72 150 64 156 54 C162 84 158 108 158 136 C158 166 132 188 100 188 Z" fill="#FF5A1F" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M100 180 C76 180 62 162 62 140 C62 114 84 96 106 58 C102 84 138 108 138 140 C138 162 122 180 100 180 Z" fill="#FF9900"/>
|
||||
<path d="M100 176 C86 176 78 166 78 152 C78 134 92 124 102 104 C110 124 122 136 122 152 C122 166 114 176 100 176 Z" fill="#FFCC00"/>
|
||||
<ellipse cx="100" cy="160" rx="9" ry="11" fill="#FFF3B0"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 924 B |
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g transform="rotate(-6 100 100)">
|
||||
<path d="M38 28 H150 L172 50 V164 Q172 172 164 172 H36 Q28 172 28 164 V38 Q28 28 38 28 Z" fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round"/>
|
||||
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<path d="M38 28 H150 L172 50 V164 Q172 172 164 172 H36 Q28 172 28 164 V38 Q28 28 38 28 Z" fill="#003399"/>
|
||||
<rect x="58" y="28" width="80" height="48" fill="#C9D1DE"/>
|
||||
<rect x="110" y="37" width="16" height="30" rx="2" fill="#003399"/>
|
||||
<rect x="46" y="96" width="108" height="76" rx="4" fill="#FFFFFF"/>
|
||||
<path d="M46 106 V100 Q46 96 50 96 H150 Q154 96 154 100 V106 Z" fill="#FF3B3B"/>
|
||||
</g>
|
||||
<g fill="#B8C8F0">
|
||||
<rect x="56" y="120" width="88" height="5" rx="2.5"/>
|
||||
<rect x="56" y="136" width="88" height="5" rx="2.5"/>
|
||||
<rect x="56" y="152" width="58" height="5" rx="2.5"/>
|
||||
</g>
|
||||
<rect x="34" y="38" width="6" height="46" rx="3" fill="#fff" opacity=".35"/>
|
||||
<rect x="64" y="34" width="5" height="36" rx="2.5" fill="#fff" opacity=".6"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,42 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
|
||||
<circle cx="100" cy="48" r="30"/>
|
||||
<circle cx="62" cy="70" r="30"/>
|
||||
<circle cx="138" cy="70" r="30"/>
|
||||
<ellipse cx="40" cy="106" rx="20" ry="9" transform="rotate(-30 40 106)"/>
|
||||
<ellipse cx="160" cy="106" rx="20" ry="9" transform="rotate(30 160 106)"/>
|
||||
<path d="M62 90 L100 112 L138 90 Z"/>
|
||||
<path d="M40 106 H160 L104 188 H96 Z"/>
|
||||
</g>
|
||||
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<ellipse cx="40" cy="106" rx="20" ry="9" transform="rotate(-30 40 106)" fill="#43A047"/>
|
||||
<ellipse cx="160" cy="106" rx="20" ry="9" transform="rotate(30 160 106)" fill="#43A047"/>
|
||||
</g>
|
||||
<g stroke="#2E7D32" stroke-width="5" stroke-linecap="round">
|
||||
<path d="M62 92 L88 110 M100 70 V110 M138 92 L112 110"/>
|
||||
</g>
|
||||
<g stroke="#333" stroke-width="2">
|
||||
<g fill="#FFFFFF">
|
||||
<circle cx="100" cy="31" r="13"/><circle cx="114.7" cy="39.5" r="13"/><circle cx="114.7" cy="56.5" r="13"/><circle cx="100" cy="65" r="13"/><circle cx="85.3" cy="56.5" r="13"/><circle cx="85.3" cy="39.5" r="13"/>
|
||||
</g>
|
||||
<circle cx="100" cy="48" r="9" fill="#FF9900"/>
|
||||
<g fill="#FF7AA2">
|
||||
<circle cx="62" cy="53" r="13"/><circle cx="76.7" cy="61.5" r="13"/><circle cx="76.7" cy="78.5" r="13"/><circle cx="62" cy="87" r="13"/><circle cx="47.3" cy="78.5" r="13"/><circle cx="47.3" cy="61.5" r="13"/>
|
||||
</g>
|
||||
<circle cx="62" cy="70" r="9" fill="#FFCC00"/>
|
||||
<g fill="#B388FF">
|
||||
<circle cx="138" cy="53" r="13"/><circle cx="152.7" cy="61.5" r="13"/><circle cx="152.7" cy="78.5" r="13"/><circle cx="138" cy="87" r="13"/><circle cx="123.3" cy="78.5" r="13"/><circle cx="123.3" cy="61.5" r="13"/>
|
||||
</g>
|
||||
<circle cx="138" cy="70" r="9" fill="#FFCC00"/>
|
||||
</g>
|
||||
<path d="M40 106 H160 L104 188 H96 Z" fill="#CCE6FF" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M112 106 H160 L104 188 Z" fill="#B3D6FA"/>
|
||||
<path d="M40 106 H160 L104 188 H96 Z" fill="none" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
|
||||
<g fill="#FF3B3B" stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<path d="M97 152 L87 173 L95 171 L100 157 Z"/>
|
||||
<path d="M103 152 L113 173 L105 171 L100 157 Z"/>
|
||||
<ellipse cx="86" cy="147" rx="14" ry="8" transform="rotate(-20 86 147)"/>
|
||||
<ellipse cx="114" cy="147" rx="14" ry="8" transform="rotate(20 114 147)"/>
|
||||
<circle cx="100" cy="150" r="6"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,23 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
|
||||
<rect x="38" y="96" width="124" height="84" rx="4"/>
|
||||
<rect x="30" y="72" width="140" height="28" rx="4"/>
|
||||
<path d="M100 72 C84 42 52 44 56 64 C58 74 84 76 100 72 Z"/>
|
||||
<path d="M100 72 C116 42 148 44 144 64 C142 74 116 76 100 72 Z"/>
|
||||
</g>
|
||||
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<rect x="38" y="96" width="124" height="84" rx="4" fill="#3366FF"/>
|
||||
<g fill="#fff" stroke="none" opacity=".45">
|
||||
<circle cx="58" cy="120" r="6"/><circle cx="70" cy="156" r="6"/><circle cx="132" cy="134" r="6"/><circle cx="148" cy="164" r="6"/>
|
||||
</g>
|
||||
<rect x="88" y="100" width="24" height="80" fill="#FFCC00"/>
|
||||
<rect x="30" y="72" width="140" height="28" rx="4" fill="#5C85FF"/>
|
||||
<rect x="86" y="72" width="28" height="28" fill="#FFCC00"/>
|
||||
<path d="M100 72 C84 42 52 44 56 64 C58 74 84 76 100 72 Z" fill="#FFCC00"/>
|
||||
<path d="M100 72 C116 42 148 44 144 64 C142 74 116 76 100 72 Z" fill="#FFCC00"/>
|
||||
<path d="M96 69 C86 56 70 54 69 62 C69 68 84 70 96 69 Z" fill="#E6A800" stroke="none"/>
|
||||
<path d="M104 69 C114 56 130 54 131 62 C131 68 116 70 104 69 Z" fill="#E6A800" stroke="none"/>
|
||||
<ellipse cx="100" cy="70" rx="10" ry="9" fill="#FFCC00"/>
|
||||
</g>
|
||||
<rect x="36" y="76" width="40" height="5" rx="2.5" fill="#fff" opacity=".45"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g transform="rotate(-6 100 100)">
|
||||
<path d="M16 58 C16 2 100 2 100 58 C100 2 184 2 184 58 C184 114 100 156 100 184 C100 156 16 114 16 58 Z" fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round"/>
|
||||
<path d="M16 58 C16 2 100 2 100 58 C100 2 184 2 184 58 C184 114 100 156 100 184 C100 156 16 114 16 58 Z" fill="#E0245E" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
|
||||
<ellipse cx="48" cy="52" rx="10" ry="19" fill="#fff" opacity=".55" transform="rotate(-35 48 52)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 561 B |
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g transform="rotate(8 100 100)">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
|
||||
<rect x="38" y="18" width="124" height="18" rx="6"/>
|
||||
<rect x="38" y="164" width="124" height="18" rx="6"/>
|
||||
<rect x="46" y="36" width="9" height="128"/>
|
||||
<rect x="145" y="36" width="9" height="128"/>
|
||||
<path d="M64 36 H136 C136 74 108 86 108 100 C108 114 136 126 136 164 H64 C64 126 92 114 92 100 C92 86 64 74 64 36 Z"/>
|
||||
</g>
|
||||
<path d="M64 36 H136 C136 74 108 86 108 100 C108 114 136 126 136 164 H64 C64 126 92 114 92 100 C92 86 64 74 64 36 Z" fill="#E6F2FF"/>
|
||||
<path d="M76 72 H124 C116 82 106 88 104 98 H96 C94 88 84 82 76 72 Z" fill="#FFCC00"/>
|
||||
<path d="M100 98 V150" stroke="#FFCC00" stroke-width="3"/>
|
||||
<path d="M68 163 C70 147 88 139 100 137 C112 139 130 147 132 163 Z" fill="#FFCC00"/>
|
||||
<path d="M73 44 C73 60 80 70 88 78" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round"/>
|
||||
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<path d="M64 36 H136 C136 74 108 86 108 100 C108 114 136 126 136 164 H64 C64 126 92 114 92 100 C92 86 64 74 64 36 Z" fill="none"/>
|
||||
<rect x="46" y="36" width="9" height="128" fill="#A0632F"/>
|
||||
<rect x="145" y="36" width="9" height="128" fill="#A0632F"/>
|
||||
<rect x="38" y="18" width="124" height="18" rx="6" fill="#C8874A"/>
|
||||
<rect x="38" y="164" width="124" height="18" rx="6" fill="#C8874A"/>
|
||||
</g>
|
||||
<g fill="#fff" opacity=".35">
|
||||
<rect x="46" y="22" width="40" height="4" rx="2"/>
|
||||
<rect x="46" y="168" width="40" height="4" rx="2"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g transform="rotate(-5 100 100)">
|
||||
<path d="M48 28 H152 Q182 28 182 58 V110 Q182 140 152 140 H88 L56 174 L64 140 H48 Q18 140 18 110 V58 Q18 28 48 28 Z" fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round"/>
|
||||
<path d="M48 28 H152 Q182 28 182 58 V110 Q182 140 152 140 H88 L56 174 L64 140 H48 Q18 140 18 110 V58 Q18 28 48 28 Z" fill="#FFFFFF" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
|
||||
<g fill="#CC0000" fill-rule="evenodd">
|
||||
<polygon transform="translate(44 0)" points="0,59 12,59 12,97 28,97 28,109 0,109"/>
|
||||
<path transform="translate(79 0)" d="M18 59 H24 A18 18 0 0 1 42 77 V91 A18 18 0 0 1 24 109 H18 A18 18 0 0 1 0 91 V77 A18 18 0 0 1 18 59 Z M19 71 H23 A7 7 0 0 1 30 78 V90 A7 7 0 0 1 23 97 H19 A7 7 0 0 1 12 90 V78 A7 7 0 0 1 19 71 Z"/>
|
||||
<polygon transform="translate(128 0)" points="0,59 12,59 12,97 28,97 28,109 0,109"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 933 B |
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g transform="rotate(-8 100 108)">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
|
||||
<rect x="22" y="56" width="156" height="104" rx="8"/>
|
||||
<path d="M138 34.6 C138 25.8 151.2 25.8 151.2 34.6 C151.2 25.8 164.4 25.8 164.4 34.6 C164.4 43.4 151.2 50 151.2 54.4 C151.2 50 138 43.4 138 34.6 Z"/>
|
||||
</g>
|
||||
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<rect x="22" y="56" width="156" height="104" rx="8" fill="#FFE0EC"/>
|
||||
<path d="M25 156 L84 112 M175 156 L116 112" fill="none" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M22 64 Q22 56 30 56 H170 Q178 56 178 64 L100 120 Z" fill="#FFC9DC"/>
|
||||
<path d="M79.6 103.8 C79.6 90.2 100 90.2 100 103.8 C100 90.2 120.4 90.2 120.4 103.8 C120.4 117.4 100 127.6 100 134.4 C100 127.6 79.6 117.4 79.6 103.8 Z" fill="#E0245E"/>
|
||||
<path d="M138 34.6 C138 25.8 151.2 25.8 151.2 34.6 C151.2 25.8 164.4 25.8 164.4 34.6 C164.4 43.4 151.2 50 151.2 54.4 C151.2 50 138 43.4 138 34.6 Z" fill="#FF99BB"/>
|
||||
</g>
|
||||
<ellipse cx="88" cy="100" rx="3" ry="5" fill="#fff" opacity=".6" transform="rotate(-30 88 100)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
|
||||
<path d="M83.7 24.4 A80 80 0 1 0 167.1 131.7 A68 68 0 0 1 83.7 24.4 Z"/>
|
||||
<polygon points="108.3,99.2 111.3,109.4 121.3,112.8 112.6,118.8 112.5,129.4 104.1,122.9 94,126.1 97.6,116.1 91.4,107.5 102,107.8"/>
|
||||
<polygon points="76.8,70.5 80.5,76 87.2,76 83.1,81.3 85.1,87.6 78.9,85.4 73.5,89.2 73.7,82.6 68.4,78.7 74.7,76.8"/>
|
||||
</g>
|
||||
<path d="M83.7 24.4 A80 80 0 1 0 167.1 131.7 A68 68 0 0 1 83.7 24.4 Z" fill="#FFE680" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
|
||||
<ellipse cx="30" cy="100" rx="5" ry="20" fill="#fff" opacity=".6" transform="rotate(8 30 100)"/>
|
||||
<g fill="#FFCC00" stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<polygon points="108.3,99.2 111.3,109.4 121.3,112.8 112.6,118.8 112.5,129.4 104.1,122.9 94,126.1 97.6,116.1 91.4,107.5 102,107.8"/>
|
||||
<polygon points="76.8,70.5 80.5,76 87.2,76 83.1,81.3 85.1,87.6 78.9,85.4 73.5,89.2 73.7,82.6 68.4,78.7 74.7,76.8"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,30 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g transform="translate(-15.3 -15.3) scale(1.15)">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
|
||||
<ellipse cx="64" cy="150" rx="20" ry="15" transform="rotate(-22 64 150)"/>
|
||||
<ellipse cx="140" cy="134" rx="20" ry="15" transform="rotate(-22 140 134)"/>
|
||||
<rect x="73" y="54" width="10" height="96"/>
|
||||
<rect x="149" y="38" width="10" height="96"/>
|
||||
<polygon points="73,54 159,38 159,62 73,78"/>
|
||||
</g>
|
||||
<g fill="#333" stroke="#333" stroke-width="3.5" stroke-linejoin="round">
|
||||
<ellipse cx="64" cy="150" rx="20" ry="15" transform="rotate(-22 64 150)"/>
|
||||
<ellipse cx="140" cy="134" rx="20" ry="15" transform="rotate(-22 140 134)"/>
|
||||
<rect x="73" y="54" width="10" height="96"/>
|
||||
<rect x="149" y="38" width="10" height="96"/>
|
||||
<polygon points="73,54 159,38 159,62 73,78"/>
|
||||
</g>
|
||||
<g fill="#5B2C8F">
|
||||
<ellipse cx="64" cy="150" rx="20" ry="15" transform="rotate(-22 64 150)"/>
|
||||
<ellipse cx="140" cy="134" rx="20" ry="15" transform="rotate(-22 140 134)"/>
|
||||
<rect x="73" y="54" width="10" height="96"/>
|
||||
<rect x="149" y="38" width="10" height="96"/>
|
||||
<polygon points="73,54 159,38 159,62 73,78"/>
|
||||
</g>
|
||||
<g fill="#fff" opacity=".55">
|
||||
<ellipse cx="56" cy="145" rx="7" ry="3.5" transform="rotate(-22 56 145)"/>
|
||||
<ellipse cx="132" cy="129" rx="7" ry="3.5" transform="rotate(-22 132 129)"/>
|
||||
<polygon points="80,58 152,45 152,49 80,62"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g transform="rotate(-10 100 100)">
|
||||
<polygon points="100,12 114,29.4 133.7,18.7 140,40.1 162.2,37.8 159.9,60 181.3,66.3 170.6,86 188,100 170.6,114 181.3,133.7 159.9,140 162.2,162.2 140,159.9 133.7,181.3 114,170.6 100,188 86,170.6 66.3,181.3 60,159.9 37.8,162.2 40.1,140 18.7,133.7 29.4,114 12,100 29.4,86 18.7,66.3 40.1,60 37.8,37.8 60,40.1 66.3,18.7 86,29.4" fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round"/>
|
||||
<polygon points="100,12 114,29.4 133.7,18.7 140,40.1 162.2,37.8 159.9,60 181.3,66.3 170.6,86 188,100 170.6,114 181.3,133.7 159.9,140 162.2,162.2 140,159.9 133.7,181.3 114,170.6 100,188 86,170.6 66.3,181.3 60,159.9 37.8,162.2 40.1,140 18.7,133.7 29.4,114 12,100 29.4,86 18.7,66.3 40.1,60 37.8,37.8 60,40.1 66.3,18.7 86,29.4" fill="#FFCC00" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
|
||||
<g fill="#CC0000">
|
||||
<polygon transform="translate(37.5 0)" points="0,121 0,79 10.4,79 22,101.6 22,79 32,79 32,121 21.6,121 10,98.4 10,121"/>
|
||||
<polygon transform="translate(73.5 0)" points="0,79 24,79 24,88 10,88 10,95.5 21,95.5 21,104.5 10,104.5 10,112 24,112 24,121 0,121"/>
|
||||
<polygon transform="translate(101.5 0)" points="0,79 11,79 14.9,99.3 17.5,91 28.5,91 31.1,99.3 35,79 46,79 38,121 27,121 23,108.4 19,121 8,121"/>
|
||||
<polygon transform="translate(151.5 0)" points="0,79 11,79 9,107 2,107"/>
|
||||
<rect x="152" y="111" width="10" height="10" rx="2"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
|
||||
<polygon points="186,18 30,84 104,122"/>
|
||||
<polygon points="186,18 104,122 130,110"/>
|
||||
<polygon points="104,122 130,110 112,156"/>
|
||||
<path d="M92 136 C76 156 52 168 40 152 C30 138 50 124 58 140 C66 156 44 178 18 182" fill="none" stroke-width="18.5"/>
|
||||
</g>
|
||||
<path d="M92 136 C76 156 52 168 40 152 C30 138 50 124 58 140 C66 156 44 178 18 182" fill="none" stroke="#3366FF" stroke-width="4.5" stroke-linecap="round" stroke-dasharray="8 8"/>
|
||||
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<polygon points="104,122 130,110 112,156" fill="#8FA8E0"/>
|
||||
<polygon points="186,18 104,122 130,110" fill="#B8C8F0"/>
|
||||
<polygon points="186,18 30,84 104,122" fill="#EEF2FF"/>
|
||||
</g>
|
||||
<path d="M164 32 L56 80" stroke="#fff" stroke-width="5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 914 B |
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g transform="rotate(-10 100 112)">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
|
||||
<path d="M30 64 Q100 46 170 64 L104 184 Q100 190 96 184 Z"/>
|
||||
<path d="M22 58 Q100 34 178 58 Q182 66 174 72 Q100 52 26 72 Q18 66 22 58 Z"/>
|
||||
</g>
|
||||
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<path d="M30 64 Q100 46 170 64 L104 184 Q100 190 96 184 Z" fill="#FFCC00"/>
|
||||
<g fill="#FFE680" stroke="none">
|
||||
<circle cx="56" cy="80" r="4.5"/><circle cx="146" cy="76" r="3.5"/><circle cx="108" cy="158" r="3.5"/><circle cx="98" cy="72" r="3"/>
|
||||
</g>
|
||||
<circle cx="72" cy="90" r="13" fill="#CC0000"/>
|
||||
<circle cx="126" cy="92" r="13" fill="#CC0000"/>
|
||||
<circle cx="99" cy="130" r="12" fill="#CC0000"/>
|
||||
<g fill="#33AA33" stroke-width="1.5">
|
||||
<ellipse cx="100" cy="98" rx="7" ry="3.5" transform="rotate(30 100 98)"/>
|
||||
<ellipse cx="80" cy="120" rx="6" ry="3" transform="rotate(-40 80 120)"/>
|
||||
<ellipse cx="116" cy="114" rx="6" ry="3" transform="rotate(50 116 114)"/>
|
||||
</g>
|
||||
<path d="M22 58 Q100 34 178 58 Q182 66 174 72 Q100 52 26 72 Q18 66 22 58 Z" fill="#E0A050"/>
|
||||
</g>
|
||||
<g fill="#fff" opacity=".5">
|
||||
<circle cx="67" cy="85" r="3.5"/><circle cx="121" cy="87" r="3.5"/><circle cx="94" cy="125" r="3"/>
|
||||
</g>
|
||||
<path d="M44 54 Q100 40 156 54" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" opacity=".5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g transform="rotate(-6 100 106)">
|
||||
<polygon points="100,18 125.9,70.4 183.7,78.8 141.8,119.6 151.7,177.2 100,150 48.3,177.2 58.2,119.6 16.3,78.8 74.1,70.4" fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round"/>
|
||||
<polygon points="100,18 125.9,70.4 183.7,78.8 141.8,119.6 151.7,177.2 100,150 48.3,177.2 58.2,119.6 16.3,78.8 74.1,70.4" fill="#FFCC00" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
|
||||
<polygon points="100,60 112.9,86.2 141.8,90.4 120.9,110.8 125.9,139.6 100,126 74.1,139.6 79.1,110.8 58.2,90.4 87.1,86.2" fill="#FFE066"/>
|
||||
<ellipse cx="93" cy="50" rx="4.5" ry="13" fill="#fff" opacity=".6" transform="rotate(25 93 50)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 730 B |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g fill="none" stroke-linecap="round">
|
||||
<circle cx="100" cy="100" r="62" fill="#fff" stroke="#fff" stroke-width="14"/>
|
||||
<path d="M164 100 H184 M155.4 132 L172.7 142 M132 155.4 L142 172.7 M100 164 V184 M68 155.4 L58 172.7 M44.6 132 L27.3 142 M36 100 H16 M44.6 68 L27.3 58 M68 44.6 L58 27.3 M100 36 V16 M132 44.6 L142 27.3 M155.4 68 L172.7 58" stroke="#fff" stroke-width="27"/>
|
||||
<path d="M164 100 H184 M155.4 132 L172.7 142 M132 155.4 L142 172.7 M100 164 V184 M68 155.4 L58 172.7 M44.6 132 L27.3 142 M36 100 H16 M44.6 68 L27.3 58 M68 44.6 L58 27.3 M100 36 V16 M132 44.6 L142 27.3 M155.4 68 L172.7 58" stroke="#333" stroke-width="13"/>
|
||||
<path d="M164 100 H184 M155.4 132 L172.7 142 M132 155.4 L142 172.7 M100 164 V184 M68 155.4 L58 172.7 M44.6 132 L27.3 142 M36 100 H16 M44.6 68 L27.3 58 M68 44.6 L58 27.3 M100 36 V16 M132 44.6 L142 27.3 M155.4 68 L172.7 58" stroke="#FF9900" stroke-width="9"/>
|
||||
</g>
|
||||
<circle cx="100" cy="100" r="52" fill="#FFCC00" stroke="#333" stroke-width="2"/>
|
||||
<ellipse cx="74" cy="68" rx="11" ry="5.5" fill="#fff" opacity=".6" transform="rotate(-38 74 68)"/>
|
||||
<circle cx="70" cy="113" r="7" fill="#FF7AA2" opacity=".65"/>
|
||||
<circle cx="130" cy="113" r="7" fill="#FF7AA2" opacity=".65"/>
|
||||
<ellipse cx="83" cy="92" rx="5" ry="8" fill="#333"/>
|
||||
<ellipse cx="117" cy="92" rx="5" ry="8" fill="#333"/>
|
||||
<path d="M78 110 Q100 130 122 110" fill="none" stroke="#333" stroke-width="4.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,36 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="16" stroke-linejoin="round">
|
||||
<rect x="18" y="96" width="40" height="88" rx="6"/>
|
||||
<rect x="50" y="92" width="70" height="88" rx="18"/>
|
||||
<rect x="92" y="88" width="68" height="26" rx="13"/>
|
||||
<rect x="92" y="112" width="74" height="26" rx="13"/>
|
||||
<rect x="92" y="136" width="70" height="24" rx="12"/>
|
||||
<rect x="92" y="158" width="62" height="22" rx="11"/>
|
||||
<path d="M56 104 C54 80 58 56 68 38 C76 24 96 22 98 38 C100 54 96 74 104 96 L100 104 Z"/>
|
||||
</g>
|
||||
<g fill="#333" stroke="#333" stroke-width="4" stroke-linejoin="round">
|
||||
<rect x="50" y="92" width="70" height="88" rx="18"/>
|
||||
<rect x="92" y="88" width="68" height="26" rx="13"/>
|
||||
<rect x="92" y="112" width="74" height="26" rx="13"/>
|
||||
<rect x="92" y="136" width="70" height="24" rx="12"/>
|
||||
<rect x="92" y="158" width="62" height="22" rx="11"/>
|
||||
<path d="M56 104 C54 80 58 56 68 38 C76 24 96 22 98 38 C100 54 96 74 104 96 L100 104 Z"/>
|
||||
</g>
|
||||
<g fill="#FFCC00">
|
||||
<rect x="50" y="92" width="70" height="88" rx="18"/>
|
||||
<rect x="92" y="88" width="68" height="26" rx="13"/>
|
||||
<rect x="92" y="112" width="74" height="26" rx="13"/>
|
||||
<rect x="92" y="136" width="70" height="24" rx="12"/>
|
||||
<rect x="92" y="158" width="62" height="22" rx="11"/>
|
||||
<path d="M56 104 C54 80 58 56 68 38 C76 24 96 22 98 38 C100 54 96 74 104 96 L100 104 Z"/>
|
||||
</g>
|
||||
<g fill="none" stroke="#333" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M108 113 H152 M108 137 H155 M108 159 H149"/>
|
||||
<path d="M66 100 C76 104 90 103 98 96"/>
|
||||
</g>
|
||||
<ellipse cx="73" cy="52" rx="4" ry="13" fill="#fff" opacity=".55" transform="rotate(18 73 52)"/>
|
||||
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<rect x="18" y="96" width="40" height="88" rx="6" fill="#3366FF"/>
|
||||
<path d="M46 96 H52 Q58 96 58 102 V178 Q58 184 52 184 H46 Z" fill="#003399"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
|
||||
<path d="M56 42 C24 40 22 82 60 90 M144 42 C176 40 178 82 140 90" fill="none" stroke-width="29"/>
|
||||
<rect x="44" y="24" width="112" height="12" rx="5"/>
|
||||
<path d="M50 30 H150 V62 C150 98 128 118 100 120 C72 118 50 98 50 62 Z"/>
|
||||
<path d="M92 118 H108 L110 140 H90 Z"/>
|
||||
<rect x="78" y="138" width="44" height="10" rx="4"/>
|
||||
<rect x="56" y="148" width="88" height="34" rx="5"/>
|
||||
</g>
|
||||
<g fill="none" stroke-linecap="round">
|
||||
<path d="M56 42 C24 40 22 82 60 90 M144 42 C176 40 178 82 140 90" stroke="#333" stroke-width="15"/>
|
||||
<path d="M56 42 C24 40 22 82 60 90 M144 42 C176 40 178 82 140 90" stroke="#FFCC00" stroke-width="11"/>
|
||||
</g>
|
||||
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<path d="M92 118 H108 L110 140 H90 Z" fill="#E6B800"/>
|
||||
<path d="M50 30 H150 V62 C150 98 128 118 100 120 C72 118 50 98 50 62 Z" fill="#FFCC00"/>
|
||||
<rect x="44" y="24" width="112" height="12" rx="5" fill="#FFE066"/>
|
||||
<rect x="78" y="138" width="44" height="10" rx="4" fill="#FFCC00"/>
|
||||
<rect x="56" y="148" width="88" height="34" rx="5" fill="#003399"/>
|
||||
<rect x="76" y="157" width="48" height="15" rx="3" fill="#FFCC00" stroke-width="1.5"/>
|
||||
<polygon points="100,53 105,63.1 116.2,64.7 108.1,72.6 110,83.8 100,78.5 90,83.8 91.9,72.6 83.8,64.7 95,63.1" fill="#FFFFFF" stroke-width="1.5"/>
|
||||
</g>
|
||||
<ellipse cx="66" cy="66" rx="5.5" ry="20" fill="#fff" opacity=".5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,22 @@
|
||||
// swift-tools-version:6.0
|
||||
import PackageDescription
|
||||
|
||||
/// Rules for the games played inside chats: chess, checkers, reversi and tic-tac-toe.
|
||||
///
|
||||
/// Both apps rebuild every game from its move log, so this package and the Android `:games`
|
||||
/// module have to agree move for move. `games-v1.json` holds the vectors both suites run.
|
||||
let package = Package(
|
||||
name: "FlamenetGames",
|
||||
platforms: [.iOS("26.0"), .macOS(.v13)],
|
||||
products: [
|
||||
.library(name: "FlamenetGames", targets: ["FlamenetGames"])
|
||||
],
|
||||
targets: [
|
||||
.target(name: "FlamenetGames"),
|
||||
.testTarget(
|
||||
name: "FlamenetGamesTests",
|
||||
dependencies: ["FlamenetGames"],
|
||||
resources: [.copy("Resources/games-v1.json")]
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,267 @@
|
||||
/// An English draughts position on squares 1-32 in standard PDN numbering.
|
||||
///
|
||||
/// Keeps the plies since the last capture or man move and the positions in that stretch, for the
|
||||
/// 80-ply draw and threefold repetition.
|
||||
public struct CheckersPosition: Hashable, Sendable {
|
||||
/// Index 0 is square 1. First's (dark) men are 1 and kings 2; second's are -1 and -2.
|
||||
let squares: [Int8]
|
||||
public let sideToMove: Side
|
||||
let quietPlies: Int
|
||||
/// The positions since the last capture or man move, oldest first, ending with this one.
|
||||
let history: [Key]
|
||||
|
||||
struct Key: Hashable, Sendable {
|
||||
let squares: [Int8]
|
||||
let side: Side
|
||||
}
|
||||
|
||||
struct Move: Hashable, Sendable {
|
||||
/// The starting square, then every square the piece lands on (indexes, not numbers).
|
||||
let path: [Int]
|
||||
let captures: Bool
|
||||
}
|
||||
|
||||
init(squares: [Int8], side: Side, quietPlies: Int, earlier: [Key]) {
|
||||
self.squares = squares
|
||||
self.sideToMove = side
|
||||
self.quietPlies = quietPlies
|
||||
self.history = earlier + [Key(squares: squares, side: side)]
|
||||
}
|
||||
|
||||
static let start = CheckersPosition(
|
||||
setup: "B:W21,22,23,24,25,26,27,28,29,30,31,32:B1,2,3,4,5,6,7,8,9,10,11,12")!
|
||||
|
||||
var repetitions: Int {
|
||||
let current = history[history.count - 1]
|
||||
return history.reduce(0) { $0 + ($1 == current ? 1 : 0) }
|
||||
}
|
||||
|
||||
/// Every move the rules allow, without the automatic draws: all capturing sequences when any
|
||||
/// capture exists (not only the longest), otherwise all steps.
|
||||
func movesByTheRules() -> [Move] {
|
||||
let us: Int8 = sideToMove == .first ? 1 : -1
|
||||
var board = squares
|
||||
var path: [Int] = []
|
||||
var jumps: [Move] = []
|
||||
for from in 0..<32 where squares[from] != 0 && (squares[from] > 0) == (us > 0) {
|
||||
path = [from]
|
||||
CheckersRules.collectJumps(&board, from, squares[from], &path, &jumps)
|
||||
}
|
||||
if !jumps.isEmpty { return jumps }
|
||||
var steps: [Move] = []
|
||||
for from in 0..<32 where squares[from] != 0 && (squares[from] > 0) == (us > 0) {
|
||||
for heading in CheckersRules.headings(for: squares[from]) {
|
||||
let to = CheckersRules.neighbour[from][heading]
|
||||
if to >= 0 && squares[to] == 0 {
|
||||
steps.append(Move(path: [from, to], captures: false))
|
||||
}
|
||||
}
|
||||
}
|
||||
return steps
|
||||
}
|
||||
}
|
||||
|
||||
extension CheckersPosition: GameRules {
|
||||
var outcome: Outcome {
|
||||
if movesByTheRules().isEmpty { return .win(sideToMove.opponent, .noMoves) }
|
||||
if quietPlies >= 80 { return .draw(.fortyMoves) }
|
||||
if repetitions >= 3 { return .draw(.repetition) }
|
||||
return .ongoing
|
||||
}
|
||||
|
||||
func legalMoveList() -> [Move] {
|
||||
quietPlies >= 80 || repetitions >= 3 ? [] : movesByTheRules()
|
||||
}
|
||||
|
||||
func notation(_ move: Move) -> String {
|
||||
move.path.map { String($0 + 1) }.joined(separator: move.captures ? "x" : "-")
|
||||
}
|
||||
|
||||
func applying(_ move: Move) -> CheckersPosition {
|
||||
var board = squares
|
||||
let piece = board[move.path[0]]
|
||||
board[move.path[0]] = 0
|
||||
if move.captures {
|
||||
for (from, to) in zip(move.path, move.path.dropFirst()) {
|
||||
let heading = (0..<4).first { CheckersRules.landing[from][$0] == to }!
|
||||
board[CheckersRules.neighbour[from][heading]] = 0
|
||||
}
|
||||
}
|
||||
let end = move.path[move.path.count - 1]
|
||||
board[end] = CheckersRules.crowns(piece, at: end) ? piece * 2 : piece
|
||||
let reset = move.captures || abs(piece) == 1
|
||||
return CheckersPosition(
|
||||
squares: board, side: sideToMove.opponent, quietPlies: reset ? 0 : quietPlies + 1,
|
||||
earlier: reset ? [] : history)
|
||||
}
|
||||
|
||||
/// `11-15` for a step, `15x24` or `15x24x31` for captures, squares 1-32 without leading zeros.
|
||||
static func isWellFormed(_ text: String) -> Bool {
|
||||
let step = text.contains("-")
|
||||
guard step != text.contains("x") else { return false }
|
||||
let parts = text.split(separator: step ? "-" : "x", omittingEmptySubsequences: false)
|
||||
guard step ? parts.count == 2 : parts.count >= 2 else { return false }
|
||||
return parts.allSatisfy { CheckersRules.number($0) != nil }
|
||||
}
|
||||
|
||||
var board: Board {
|
||||
var cells: [Piece?] = []
|
||||
var names: [String?] = []
|
||||
for row in 0..<8 {
|
||||
for column in 0..<8 {
|
||||
// The first player sees the standard diagram turned round, own men at the bottom.
|
||||
guard let index = CheckersRules.index(row: 7 - row, column: 7 - column) else {
|
||||
cells.append(nil)
|
||||
names.append(nil)
|
||||
continue
|
||||
}
|
||||
let piece = squares[index]
|
||||
cells.append(
|
||||
piece == 0
|
||||
? nil
|
||||
: Piece(
|
||||
side: piece > 0 ? .first : .second, kind: abs(piece) == 2 ? .king : .man
|
||||
)
|
||||
)
|
||||
names.append(String(index + 1))
|
||||
}
|
||||
}
|
||||
return Board(rows: 8, columns: 8, cells: cells, squares: names)
|
||||
}
|
||||
|
||||
/// PDN FEN with the second (W) list first, as in `B:W21,...,32:B1,...,12`.
|
||||
var setup: String {
|
||||
func list(_ sign: Int8) -> String {
|
||||
(0..<32).compactMap { index -> String? in
|
||||
let piece = squares[index]
|
||||
guard piece != 0, (piece > 0) == (sign > 0) else { return nil }
|
||||
return (abs(piece) == 2 ? "K" : "") + String(index + 1)
|
||||
}.joined(separator: ",")
|
||||
}
|
||||
return "\(sideToMove == .first ? "B" : "W"):W\(list(-1)):B\(list(1))"
|
||||
}
|
||||
|
||||
/// Reads PDN FEN: the side to move (B first, W second), then both piece lists in either
|
||||
/// order, K marking a king. A man on the row where it would have been crowned is refused.
|
||||
init?(setup: String) {
|
||||
let fields = setup.split(separator: ":", omittingEmptySubsequences: false)
|
||||
guard fields.count == 3 else { return nil }
|
||||
let side: Side
|
||||
switch fields[0] {
|
||||
case "B": side = .first
|
||||
case "W": side = .second
|
||||
default: return nil
|
||||
}
|
||||
var board = [Int8](repeating: 0, count: 32)
|
||||
var listed: Set<Character> = []
|
||||
for field in fields[1...] {
|
||||
guard let colour = field.first, colour == "B" || colour == "W",
|
||||
listed.insert(colour).inserted
|
||||
else { return nil }
|
||||
let sign: Int8 = colour == "B" ? 1 : -1
|
||||
let items = field.dropFirst()
|
||||
if items.isEmpty { continue }
|
||||
for item in items.split(separator: ",", omittingEmptySubsequences: false) {
|
||||
let crowned = item.first == "K"
|
||||
guard let number = CheckersRules.number(crowned ? item.dropFirst() : item),
|
||||
board[number - 1] == 0
|
||||
else { return nil }
|
||||
let piece = crowned ? sign * 2 : sign
|
||||
guard !CheckersRules.crowns(piece, at: number - 1) else { return nil }
|
||||
board[number - 1] = piece
|
||||
}
|
||||
}
|
||||
self.init(squares: board, side: side, quietPlies: 0, earlier: [])
|
||||
}
|
||||
}
|
||||
|
||||
enum CheckersRules {
|
||||
/// Rows as in the standard diagram: row 0 holds squares 1-4, row 7 holds 29-32.
|
||||
static func row(_ index: Int) -> Int { index / 4 }
|
||||
|
||||
static func column(_ index: Int) -> Int {
|
||||
row(index) % 2 == 0 ? 2 * (index % 4) + 1 : 2 * (index % 4)
|
||||
}
|
||||
|
||||
static func index(row: Int, column: Int) -> Int? {
|
||||
guard row >= 0 && row < 8 && column >= 0 && column < 8 && (row + column) % 2 == 1 else {
|
||||
return nil
|
||||
}
|
||||
return row * 4 + column / 2
|
||||
}
|
||||
|
||||
/// (row, column) steps. Headings 0 and 1 lead toward row 7, where first's men are crowned.
|
||||
static let steps = [(1, -1), (1, 1), (-1, -1), (-1, 1)]
|
||||
|
||||
/// The square one step away in each heading, or -1 off the board.
|
||||
static let neighbour: [[Int]] = (0..<32).map { index in
|
||||
steps.map { step in
|
||||
CheckersRules.index(row: row(index) + step.0, column: column(index) + step.1) ?? -1
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a jump in each heading lands, or -1 off the board.
|
||||
static let landing: [[Int]] = (0..<32).map { index in
|
||||
(0..<4).map { heading in
|
||||
let next = neighbour[index][heading]
|
||||
return next < 0 ? -1 : neighbour[next][heading]
|
||||
}
|
||||
}
|
||||
|
||||
static func headings(for piece: Int8) -> [Int] {
|
||||
switch piece {
|
||||
case 1: return [0, 1]
|
||||
case -1: return [2, 3]
|
||||
default: return [0, 1, 2, 3]
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a man that ends on `index` is crowned there.
|
||||
static func crowns(_ piece: Int8, at index: Int) -> Bool {
|
||||
(piece == 1 && row(index) == 7) || (piece == -1 && row(index) == 0)
|
||||
}
|
||||
|
||||
/// A square number 1-32 written without leading zeros.
|
||||
static func number(_ text: Substring) -> Int? {
|
||||
let bytes = Array(text.utf8)
|
||||
guard (1...2).contains(bytes.count), bytes[0] != UInt8(ascii: "0"),
|
||||
bytes.allSatisfy({ (UInt8(ascii: "0")...UInt8(ascii: "9")).contains($0) }),
|
||||
let value = Int(text), (1...32).contains(value)
|
||||
else { return nil }
|
||||
return value
|
||||
}
|
||||
|
||||
/// Adds every complete capturing sequence for the piece on `at`. A sequence goes on while
|
||||
/// the piece can jump again, and stops where a man is crowned. Captured pieces come off as
|
||||
/// they are jumped; with one-square kings that is the same as lifting them at the end.
|
||||
static func collectJumps(
|
||||
_ board: inout [Int8], _ at: Int, _ piece: Int8, _ path: inout [Int],
|
||||
_ found: inout [CheckersPosition.Move]
|
||||
) {
|
||||
var jumped = false
|
||||
for heading in headings(for: piece) {
|
||||
let over = neighbour[at][heading]
|
||||
let to = landing[at][heading]
|
||||
guard to >= 0, board[to] == 0, board[over] != 0, (board[over] > 0) != (piece > 0)
|
||||
else { continue }
|
||||
jumped = true
|
||||
let taken = board[over]
|
||||
board[over] = 0
|
||||
board[at] = 0
|
||||
board[to] = piece
|
||||
path.append(to)
|
||||
if crowns(piece, at: to) {
|
||||
found.append(CheckersPosition.Move(path: path, captures: true))
|
||||
} else {
|
||||
collectJumps(&board, to, piece, &path, &found)
|
||||
}
|
||||
path.removeLast()
|
||||
board[to] = 0
|
||||
board[at] = piece
|
||||
board[over] = taken
|
||||
}
|
||||
if !jumped && path.count > 1 {
|
||||
found.append(CheckersPosition.Move(path: path, captures: true))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
/// A chess position.
|
||||
///
|
||||
/// Besides the placement it keeps what the rules need: castling rights, the en passant square
|
||||
/// (only while an en passant capture is legal, which is also what repetition compares), the
|
||||
/// clocks, and the positions since the last capture or pawn move, for threefold repetition.
|
||||
public struct ChessPosition: Hashable, Sendable {
|
||||
/// Square 0 is a1, 7 is h1, 63 is h8. First's (white) pieces are positive, second's negative.
|
||||
let squares: [Int8]
|
||||
public let sideToMove: Side
|
||||
let castling: UInt8
|
||||
/// -1 when no en passant capture is legal.
|
||||
let enPassant: Int
|
||||
let halfmoveClock: Int
|
||||
let fullmoveNumber: Int
|
||||
/// The positions since the last capture or pawn move, oldest first, ending with this one.
|
||||
let history: [Key]
|
||||
|
||||
/// What makes two positions the same for repetition.
|
||||
struct Key: Hashable, Sendable {
|
||||
let squares: [Int8]
|
||||
let side: Side
|
||||
let castling: UInt8
|
||||
let enPassant: Int
|
||||
}
|
||||
|
||||
struct Move: Hashable, Sendable {
|
||||
let from: Int
|
||||
let to: Int
|
||||
/// 0, or the code of the piece a pawn becomes.
|
||||
let promotion: Int8
|
||||
}
|
||||
|
||||
init(
|
||||
squares: [Int8], side: Side, castling: UInt8, enPassant: Int, clock: Int, fullmove: Int,
|
||||
earlier: [Key]
|
||||
) {
|
||||
self.squares = squares
|
||||
self.sideToMove = side
|
||||
self.castling = castling
|
||||
self.enPassant = enPassant
|
||||
self.halfmoveClock = clock
|
||||
self.fullmoveNumber = fullmove
|
||||
self.history =
|
||||
earlier + [Key(squares: squares, side: side, castling: castling, enPassant: enPassant)]
|
||||
}
|
||||
|
||||
static let start = ChessPosition(
|
||||
setup: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")!
|
||||
|
||||
/// Whether the side to move is in check.
|
||||
public var inCheck: Bool {
|
||||
ChessRules.isAttacked(squares, kingSquare(sign), by: -sign)
|
||||
}
|
||||
|
||||
var sign: Int8 { sideToMove == .first ? 1 : -1 }
|
||||
|
||||
func kingSquare(_ sign: Int8) -> Int {
|
||||
// Every position has exactly one king a side: setup refuses anything else.
|
||||
squares.firstIndex(of: sign * ChessRules.king)!
|
||||
}
|
||||
|
||||
var repetitions: Int {
|
||||
let current = history[history.count - 1]
|
||||
return history.reduce(0) { $0 + ($1 == current ? 1 : 0) }
|
||||
}
|
||||
|
||||
/// K v K, K+B v K, K+N v K, and K+B v K+B with both bishops on one colour. Nothing else.
|
||||
var insufficientMaterial: Bool {
|
||||
var others: [(piece: Int8, square: Int)] = []
|
||||
for (square, piece) in squares.enumerated()
|
||||
where piece != 0 && abs(piece) != ChessRules.king {
|
||||
others.append((piece, square))
|
||||
if others.count > 2 { return false }
|
||||
}
|
||||
switch others.count {
|
||||
case 0:
|
||||
return true
|
||||
case 1:
|
||||
let kind = abs(others[0].piece)
|
||||
return kind == ChessRules.bishop || kind == ChessRules.knight
|
||||
default:
|
||||
let (a, b) = (others[0], others[1])
|
||||
return abs(a.piece) == ChessRules.bishop && abs(b.piece) == ChessRules.bishop
|
||||
&& (a.piece > 0) != (b.piece > 0)
|
||||
&& ChessRules.colour(a.square) == ChessRules.colour(b.square)
|
||||
}
|
||||
}
|
||||
|
||||
var drawnAutomatically: Bool {
|
||||
insufficientMaterial || halfmoveClock >= 100 || repetitions >= 3
|
||||
}
|
||||
|
||||
/// Every move the rules of movement allow, without the automatic draws.
|
||||
func movesByTheRules() -> [Move] {
|
||||
let us = sign
|
||||
let king = kingSquare(us)
|
||||
var board = squares
|
||||
var legal: [Move] = []
|
||||
for move in pseudoLegalMoves() {
|
||||
let moving = board[move.from]
|
||||
let taken = board[move.to]
|
||||
board[move.to] = move.promotion != 0 ? us * move.promotion : moving
|
||||
board[move.from] = 0
|
||||
var victim = -1
|
||||
if abs(moving) == ChessRules.pawn && move.to == enPassant && taken == 0
|
||||
&& move.to % 8 != move.from % 8
|
||||
{
|
||||
victim = move.to - 8 * Int(us)
|
||||
board[victim] = 0
|
||||
}
|
||||
let guarded = abs(moving) == ChessRules.king ? move.to : king
|
||||
if !ChessRules.isAttacked(board, guarded, by: -us) {
|
||||
legal.append(move)
|
||||
}
|
||||
board[move.from] = moving
|
||||
board[move.to] = taken
|
||||
if victim >= 0 { board[victim] = -us * ChessRules.pawn }
|
||||
}
|
||||
return legal
|
||||
}
|
||||
|
||||
/// Moves by the pieces' rules of movement, before checking the mover's king is safe.
|
||||
private func pseudoLegalMoves() -> [Move] {
|
||||
let us = sign
|
||||
var moves: [Move] = []
|
||||
moves.reserveCapacity(64)
|
||||
for from in 0..<64 {
|
||||
let piece = squares[from]
|
||||
guard piece != 0, (piece > 0) == (us > 0) else { continue }
|
||||
switch abs(piece) {
|
||||
case ChessRules.pawn: addPawnMoves(&moves, from)
|
||||
case ChessRules.knight: addSteps(&moves, from, ChessRules.knightSteps)
|
||||
case ChessRules.bishop: addSlides(&moves, from, ChessRules.diagonal)
|
||||
case ChessRules.rook: addSlides(&moves, from, ChessRules.straight)
|
||||
case ChessRules.queen:
|
||||
addSlides(&moves, from, ChessRules.straight)
|
||||
addSlides(&moves, from, ChessRules.diagonal)
|
||||
default:
|
||||
addSteps(&moves, from, ChessRules.kingSteps)
|
||||
addCastling(&moves, from)
|
||||
}
|
||||
}
|
||||
return moves
|
||||
}
|
||||
|
||||
private func addPawnMoves(_ moves: inout [Move], _ from: Int) {
|
||||
let us = sign
|
||||
let file = from % 8
|
||||
let rank = from / 8
|
||||
// A pawn never stands on its last rank, so the rank ahead is always on the board.
|
||||
let ahead = rank + Int(us)
|
||||
let promotes = ahead == (us > 0 ? 7 : 0)
|
||||
func add(_ to: Int) {
|
||||
if promotes {
|
||||
for piece in ChessRules.promotions {
|
||||
moves.append(Move(from: from, to: to, promotion: piece))
|
||||
}
|
||||
} else {
|
||||
moves.append(Move(from: from, to: to, promotion: 0))
|
||||
}
|
||||
}
|
||||
let one = ahead * 8 + file
|
||||
if squares[one] == 0 {
|
||||
add(one)
|
||||
let two = one + 8 * Int(us)
|
||||
if rank == (us > 0 ? 1 : 6) && squares[two] == 0 {
|
||||
moves.append(Move(from: from, to: two, promotion: 0))
|
||||
}
|
||||
}
|
||||
for step in [-1, 1] {
|
||||
let target = file + step
|
||||
guard target >= 0 && target < 8 else { continue }
|
||||
let to = ahead * 8 + target
|
||||
let there = squares[to]
|
||||
if there != 0 && (there > 0) != (us > 0) {
|
||||
add(to)
|
||||
} else if there == 0 && to == enPassant {
|
||||
moves.append(Move(from: from, to: to, promotion: 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addSteps(_ moves: inout [Move], _ from: Int, _ steps: [(Int, Int)]) {
|
||||
let file = from % 8
|
||||
let rank = from / 8
|
||||
for (df, dr) in steps {
|
||||
let f = file + df
|
||||
let r = rank + dr
|
||||
guard f >= 0 && f < 8 && r >= 0 && r < 8 else { continue }
|
||||
let there = squares[r * 8 + f]
|
||||
if there == 0 || (there > 0) != (sign > 0) {
|
||||
moves.append(Move(from: from, to: r * 8 + f, promotion: 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addSlides(_ moves: inout [Move], _ from: Int, _ directions: [(Int, Int)]) {
|
||||
let file = from % 8
|
||||
let rank = from / 8
|
||||
for (df, dr) in directions {
|
||||
var f = file + df
|
||||
var r = rank + dr
|
||||
while f >= 0 && f < 8 && r >= 0 && r < 8 {
|
||||
let there = squares[r * 8 + f]
|
||||
if there == 0 || (there > 0) != (sign > 0) {
|
||||
moves.append(Move(from: from, to: r * 8 + f, promotion: 0))
|
||||
}
|
||||
if there != 0 { break }
|
||||
f += df
|
||||
r += dr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addCastling(_ moves: inout [Move], _ home: Int) {
|
||||
let us = sign
|
||||
guard home == (us > 0 ? 4 : 60) else { return }
|
||||
let kingside = us > 0 ? ChessRules.whiteKingside : ChessRules.blackKingside
|
||||
let queenside = us > 0 ? ChessRules.whiteQueenside : ChessRules.blackQueenside
|
||||
let rook = us * ChessRules.rook
|
||||
func attacked(_ square: Int) -> Bool {
|
||||
ChessRules.isAttacked(squares, square, by: -us)
|
||||
}
|
||||
// Not out of, through or into check. On the queen's side b1 (b8) only has to be empty.
|
||||
if castling & kingside != 0 && squares[home + 1] == 0 && squares[home + 2] == 0
|
||||
&& squares[home + 3] == rook && !attacked(home) && !attacked(home + 1)
|
||||
&& !attacked(home + 2)
|
||||
{
|
||||
moves.append(Move(from: home, to: home + 2, promotion: 0))
|
||||
}
|
||||
if castling & queenside != 0 && squares[home - 1] == 0 && squares[home - 2] == 0
|
||||
&& squares[home - 3] == 0 && squares[home - 4] == rook && !attacked(home)
|
||||
&& !attacked(home - 1) && !attacked(home - 2)
|
||||
{
|
||||
moves.append(Move(from: home, to: home - 2, promotion: 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ChessPosition: GameRules {
|
||||
var outcome: Outcome {
|
||||
if movesByTheRules().isEmpty {
|
||||
return inCheck ? .win(sideToMove.opponent, .checkmate) : .draw(.stalemate)
|
||||
}
|
||||
if insufficientMaterial { return .draw(.insufficientMaterial) }
|
||||
if halfmoveClock >= 100 { return .draw(.fiftyMoves) }
|
||||
if repetitions >= 3 { return .draw(.repetition) }
|
||||
return .ongoing
|
||||
}
|
||||
|
||||
func legalMoveList() -> [Move] {
|
||||
drawnAutomatically ? [] : movesByTheRules()
|
||||
}
|
||||
|
||||
func notation(_ move: Move) -> String {
|
||||
var text = ChessRules.name(move.from) + ChessRules.name(move.to)
|
||||
if move.promotion != 0 {
|
||||
text.append(ChessRules.letters[Int(move.promotion) - 1])
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func applying(_ move: Move) -> ChessPosition {
|
||||
let us = sign
|
||||
var board = squares
|
||||
let moving = board[move.from]
|
||||
let kind = abs(moving)
|
||||
var captured = board[move.to] != 0
|
||||
board[move.to] = move.promotion != 0 ? us * move.promotion : moving
|
||||
board[move.from] = 0
|
||||
if kind == ChessRules.pawn && move.to == enPassant && !captured
|
||||
&& move.to % 8 != move.from % 8
|
||||
{
|
||||
board[move.to - 8 * Int(us)] = 0
|
||||
captured = true
|
||||
}
|
||||
if kind == ChessRules.king && abs(move.to - move.from) == 2 {
|
||||
// Castling: the rook lands on the square the king crossed.
|
||||
let (rookFrom, rookTo) =
|
||||
move.to > move.from
|
||||
? (move.from + 3, move.from + 1) : (move.from - 4, move.from - 1)
|
||||
board[rookTo] = board[rookFrom]
|
||||
board[rookFrom] = 0
|
||||
}
|
||||
var rights = castling
|
||||
if kind == ChessRules.king {
|
||||
rights &=
|
||||
us > 0
|
||||
? ~(ChessRules.whiteKingside | ChessRules.whiteQueenside)
|
||||
: ~(ChessRules.blackKingside | ChessRules.blackQueenside)
|
||||
}
|
||||
rights &= ~ChessRules.rightTiedTo(move.from) & ~ChessRules.rightTiedTo(move.to)
|
||||
var target = -1
|
||||
if kind == ChessRules.pawn && abs(move.to - move.from) == 16 {
|
||||
let skipped = (move.from + move.to) / 2
|
||||
if ChessRules.enPassantIsLegal(board, target: skipped, by: -us) {
|
||||
target = skipped
|
||||
}
|
||||
}
|
||||
let reset = captured || kind == ChessRules.pawn
|
||||
return ChessPosition(
|
||||
squares: board, side: sideToMove.opponent, castling: rights, enPassant: target,
|
||||
clock: reset ? 0 : halfmoveClock + 1,
|
||||
fullmove: fullmoveNumber + (sideToMove == .second ? 1 : 0),
|
||||
earlier: reset ? [] : history)
|
||||
}
|
||||
|
||||
static func isWellFormed(_ text: String) -> Bool {
|
||||
let bytes = Array(text.utf8)
|
||||
guard bytes.count == 4 || bytes.count == 5 else { return false }
|
||||
let files = UInt8(ascii: "a")...UInt8(ascii: "h")
|
||||
let ranks = UInt8(ascii: "1")...UInt8(ascii: "8")
|
||||
guard files.contains(bytes[0]), ranks.contains(bytes[1]), files.contains(bytes[2]),
|
||||
ranks.contains(bytes[3])
|
||||
else { return false }
|
||||
return bytes.count == 4 || "qrbn".utf8.contains(bytes[4])
|
||||
}
|
||||
|
||||
var board: Board {
|
||||
var cells: [Piece?] = []
|
||||
var names: [String?] = []
|
||||
for row in 0..<8 {
|
||||
for column in 0..<8 {
|
||||
let square = (7 - row) * 8 + column
|
||||
let piece = squares[square]
|
||||
cells.append(
|
||||
piece == 0
|
||||
? nil
|
||||
: Piece(
|
||||
side: piece > 0 ? .first : .second,
|
||||
kind: ChessRules.kinds[Int(abs(piece)) - 1]))
|
||||
names.append(ChessRules.name(square))
|
||||
}
|
||||
}
|
||||
return Board(rows: 8, columns: 8, cells: cells, squares: names)
|
||||
}
|
||||
|
||||
/// FEN, with the en passant square shown only while the capture is legal.
|
||||
var setup: String {
|
||||
var text = ""
|
||||
for rank in stride(from: 7, through: 0, by: -1) {
|
||||
var empty = 0
|
||||
for file in 0..<8 {
|
||||
let piece = squares[rank * 8 + file]
|
||||
if piece == 0 {
|
||||
empty += 1
|
||||
continue
|
||||
}
|
||||
if empty > 0 { text += String(empty) }
|
||||
empty = 0
|
||||
let letter = ChessRules.letters[Int(abs(piece)) - 1]
|
||||
text.append(piece > 0 ? Character(letter.uppercased()) : letter)
|
||||
}
|
||||
if empty > 0 { text += String(empty) }
|
||||
if rank > 0 { text += "/" }
|
||||
}
|
||||
var rights = ""
|
||||
if castling & ChessRules.whiteKingside != 0 { rights += "K" }
|
||||
if castling & ChessRules.whiteQueenside != 0 { rights += "Q" }
|
||||
if castling & ChessRules.blackKingside != 0 { rights += "k" }
|
||||
if castling & ChessRules.blackQueenside != 0 { rights += "q" }
|
||||
let side = sideToMove == .first ? "w" : "b"
|
||||
let target = enPassant >= 0 ? ChessRules.name(enPassant) : "-"
|
||||
return
|
||||
"\(text) \(side) \(rights.isEmpty ? "-" : rights) \(target) \(halfmoveClock) \(fullmoveNumber)"
|
||||
}
|
||||
|
||||
/// Reads FEN (four or six fields). Refuses positions the rules cannot reach: a side without
|
||||
/// exactly one king, a pawn on the first or last rank, the side not to move in check, or an
|
||||
/// en passant square with no pawn that just made the double step.
|
||||
init?(setup: String) {
|
||||
let fields = setup.split(separator: " ", omittingEmptySubsequences: false)
|
||||
guard fields.count == 6 || fields.count == 4 else { return nil }
|
||||
let rows = fields[0].split(separator: "/", omittingEmptySubsequences: false)
|
||||
guard rows.count == 8 else { return nil }
|
||||
var board = [Int8](repeating: 0, count: 64)
|
||||
for (index, row) in rows.enumerated() {
|
||||
let rank = 7 - index
|
||||
var file = 0
|
||||
for character in row {
|
||||
if let gap = character.wholeNumberValue, character.isASCII, (1...8).contains(gap) {
|
||||
file += gap
|
||||
} else {
|
||||
guard file < 8, let code = ChessRules.code(character) else { return nil }
|
||||
board[rank * 8 + file] = code
|
||||
file += 1
|
||||
}
|
||||
guard file <= 8 else { return nil }
|
||||
}
|
||||
guard file == 8 else { return nil }
|
||||
}
|
||||
let side: Side
|
||||
switch fields[1] {
|
||||
case "w": side = .first
|
||||
case "b": side = .second
|
||||
default: return nil
|
||||
}
|
||||
var rights: UInt8 = 0
|
||||
if fields[2] != "-" {
|
||||
guard !fields[2].isEmpty else { return nil }
|
||||
for character in fields[2] {
|
||||
guard let bit = ChessRules.rightBits[character], rights & bit == 0 else {
|
||||
return nil
|
||||
}
|
||||
rights |= bit
|
||||
}
|
||||
}
|
||||
// A right without its king and rook at home could never be used; drop it.
|
||||
let rook = ChessRules.rook
|
||||
let king = ChessRules.king
|
||||
if board[4] != king || board[7] != rook { rights &= ~ChessRules.whiteKingside }
|
||||
if board[4] != king || board[0] != rook { rights &= ~ChessRules.whiteQueenside }
|
||||
if board[60] != -king || board[63] != -rook { rights &= ~ChessRules.blackKingside }
|
||||
if board[60] != -king || board[56] != -rook { rights &= ~ChessRules.blackQueenside }
|
||||
|
||||
guard board.filter({ $0 == king }).count == 1, board.filter({ $0 == -king }).count == 1
|
||||
else { return nil }
|
||||
for file in 0..<8
|
||||
where abs(board[file]) == ChessRules.pawn || abs(board[56 + file]) == ChessRules.pawn {
|
||||
return nil
|
||||
}
|
||||
let us: Int8 = side == .first ? 1 : -1
|
||||
guard let theirKing = board.firstIndex(of: -us * king),
|
||||
!ChessRules.isAttacked(board, theirKing, by: us)
|
||||
else { return nil }
|
||||
|
||||
var target = -1
|
||||
if fields[3] != "-" {
|
||||
guard let square = ChessRules.square(named: fields[3]),
|
||||
square / 8 == (us > 0 ? 5 : 2)
|
||||
else { return nil }
|
||||
// The pawn that stepped two squares stands in front of the target, its start is empty.
|
||||
let pushed = square - 8 * Int(us)
|
||||
let origin = square + 8 * Int(us)
|
||||
guard board[pushed] == -us * ChessRules.pawn, board[square] == 0, board[origin] == 0
|
||||
else { return nil }
|
||||
if ChessRules.enPassantIsLegal(board, target: square, by: us) {
|
||||
target = square
|
||||
}
|
||||
}
|
||||
var clock = 0
|
||||
var fullmove = 1
|
||||
if fields.count == 6 {
|
||||
guard let c = Int(fields[4]), c >= 0, let f = Int(fields[5]), f >= 1 else {
|
||||
return nil
|
||||
}
|
||||
clock = c
|
||||
fullmove = f
|
||||
}
|
||||
self.init(
|
||||
squares: board, side: side, castling: rights, enPassant: target, clock: clock,
|
||||
fullmove: fullmove, earlier: [])
|
||||
}
|
||||
}
|
||||
|
||||
enum ChessRules {
|
||||
static let pawn: Int8 = 1
|
||||
static let knight: Int8 = 2
|
||||
static let bishop: Int8 = 3
|
||||
static let rook: Int8 = 4
|
||||
static let queen: Int8 = 5
|
||||
static let king: Int8 = 6
|
||||
|
||||
static let whiteKingside: UInt8 = 1
|
||||
static let whiteQueenside: UInt8 = 2
|
||||
static let blackKingside: UInt8 = 4
|
||||
static let blackQueenside: UInt8 = 8
|
||||
static let rightBits: [Character: UInt8] = [
|
||||
"K": whiteKingside, "Q": whiteQueenside, "k": blackKingside, "q": blackQueenside,
|
||||
]
|
||||
|
||||
/// Indexed by piece code minus one.
|
||||
static let letters: [Character] = ["p", "n", "b", "r", "q", "k"]
|
||||
static let kinds: [PieceKind] = [.pawn, .knight, .bishop, .rook, .queen, .king]
|
||||
/// The order promotions are listed in.
|
||||
static let promotions: [Int8] = [queen, rook, bishop, knight]
|
||||
|
||||
/// (file, rank) steps.
|
||||
static let knightSteps = [
|
||||
(1, 2), (2, 1), (2, -1), (1, -2), (-1, -2), (-2, -1), (-2, 1), (-1, 2),
|
||||
]
|
||||
static let kingSteps = [(0, 1), (1, 1), (1, 0), (1, -1), (0, -1), (-1, -1), (-1, 0), (-1, 1)]
|
||||
static let straight = [(0, 1), (0, -1), (1, 0), (-1, 0)]
|
||||
static let diagonal = [(1, 1), (-1, 1), (1, -1), (-1, -1)]
|
||||
|
||||
static func name(_ square: Int) -> String {
|
||||
let file = Character(UnicodeScalar(UInt8(ascii: "a") + UInt8(square % 8)))
|
||||
let rank = Character(UnicodeScalar(UInt8(ascii: "1") + UInt8(square / 8)))
|
||||
return String([file, rank])
|
||||
}
|
||||
|
||||
static func square(named text: Substring) -> Int? {
|
||||
let bytes = Array(text.utf8)
|
||||
guard bytes.count == 2,
|
||||
(UInt8(ascii: "a")...UInt8(ascii: "h")).contains(bytes[0]),
|
||||
(UInt8(ascii: "1")...UInt8(ascii: "8")).contains(bytes[1])
|
||||
else { return nil }
|
||||
return Int(bytes[1] - UInt8(ascii: "1")) * 8 + Int(bytes[0] - UInt8(ascii: "a"))
|
||||
}
|
||||
|
||||
static func code(_ character: Character) -> Int8? {
|
||||
guard character.isASCII,
|
||||
let index = letters.firstIndex(of: Character(character.lowercased()))
|
||||
else { return nil }
|
||||
let code = Int8(index + 1)
|
||||
return character.isUppercase ? code : -code
|
||||
}
|
||||
|
||||
/// 0 for a dark square, 1 for a light one.
|
||||
static func colour(_ square: Int) -> Int {
|
||||
(square % 8 + square / 8) % 2
|
||||
}
|
||||
|
||||
/// The castling right lost when a piece leaves or is taken on this corner.
|
||||
static func rightTiedTo(_ square: Int) -> UInt8 {
|
||||
switch square {
|
||||
case 0: return whiteQueenside
|
||||
case 7: return whiteKingside
|
||||
case 56: return blackQueenside
|
||||
case 63: return blackKingside
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a piece of the side with `sign` (1 white, -1 black) attacks `square`.
|
||||
static func isAttacked(_ board: [Int8], _ square: Int, by sign: Int8) -> Bool {
|
||||
let file = square % 8
|
||||
let rank = square / 8
|
||||
// A pawn attacks the rank ahead of it, so a white attacker stands one rank lower.
|
||||
let pawnRank = rank - Int(sign)
|
||||
if pawnRank >= 0 && pawnRank < 8 {
|
||||
for step in [-1, 1] {
|
||||
let f = file + step
|
||||
if f >= 0 && f < 8 && board[pawnRank * 8 + f] == sign * pawn { return true }
|
||||
}
|
||||
}
|
||||
if reaches(board, file, rank, knightSteps, sign * knight)
|
||||
|| reaches(board, file, rank, kingSteps, sign * king)
|
||||
{
|
||||
return true
|
||||
}
|
||||
return slides(board, file, rank, straight, sign * rook, sign * queen)
|
||||
|| slides(board, file, rank, diagonal, sign * bishop, sign * queen)
|
||||
}
|
||||
|
||||
private static func reaches(
|
||||
_ board: [Int8], _ file: Int, _ rank: Int, _ steps: [(Int, Int)], _ piece: Int8
|
||||
) -> Bool {
|
||||
for (df, dr) in steps {
|
||||
let f = file + df
|
||||
let r = rank + dr
|
||||
if f >= 0 && f < 8 && r >= 0 && r < 8 && board[r * 8 + f] == piece { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Whether the first piece met along any direction is `slider` or `queen`.
|
||||
private static func slides(
|
||||
_ board: [Int8], _ file: Int, _ rank: Int, _ directions: [(Int, Int)], _ slider: Int8,
|
||||
_ queen: Int8
|
||||
) -> Bool {
|
||||
for (df, dr) in directions {
|
||||
var f = file + df
|
||||
var r = rank + dr
|
||||
while f >= 0 && f < 8 && r >= 0 && r < 8 {
|
||||
let piece = board[r * 8 + f]
|
||||
if piece != 0 {
|
||||
if piece == slider || piece == queen { return true }
|
||||
break
|
||||
}
|
||||
f += df
|
||||
r += dr
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Whether the side with `sign` can legally capture en passant onto `target`.
|
||||
static func enPassantIsLegal(_ board: [Int8], target: Int, by sign: Int8) -> Bool {
|
||||
let victim = target - 8 * Int(sign)
|
||||
guard let king = board.firstIndex(of: sign * self.king) else { return false }
|
||||
for step in [-1, 1] {
|
||||
let file = victim % 8 + step
|
||||
guard file >= 0 && file < 8 else { continue }
|
||||
let from = victim - victim % 8 + file
|
||||
guard board[from] == sign * pawn else { continue }
|
||||
var after = board
|
||||
after[target] = sign * pawn
|
||||
after[from] = 0
|
||||
after[victim] = 0
|
||||
if !isAttacked(after, king, by: -sign) { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
/// The games played inside chats, by their wire names (DESIGN.md section 2).
|
||||
public enum GameType: String, CaseIterable, Hashable, Sendable {
|
||||
case chess
|
||||
case checkers
|
||||
case reversi
|
||||
case tictactoe
|
||||
|
||||
/// The longest log accepted for this game. A longer one is refused before any of it is played.
|
||||
public var maxPlies: Int {
|
||||
switch self {
|
||||
case .chess: return 600
|
||||
case .checkers: return 400
|
||||
case .reversi: return 130
|
||||
case .tictactoe: return 9
|
||||
}
|
||||
}
|
||||
|
||||
/// The side that played entry `index` (zero-based) of a log played from the start.
|
||||
///
|
||||
/// Every entry, a reversi `pass` included, is one ply by the side to move, so the sides
|
||||
/// alternate and entry 0 is always `first`'s. A receiver checks each new entry in a peer's log
|
||||
/// against the peer's side with this.
|
||||
public func side(ofPly index: Int) -> Side {
|
||||
index % 2 == 0 ? .first : .second
|
||||
}
|
||||
}
|
||||
|
||||
/// Who plays. `first` is chess white, checkers dark, reversi black and tic-tac-toe X.
|
||||
public enum Side: String, CaseIterable, Hashable, Sendable {
|
||||
case first
|
||||
case second
|
||||
|
||||
public var opponent: Side { self == .first ? .second : .first }
|
||||
}
|
||||
|
||||
/// Why a game was won. The raw values are the names used in `games-v1.json`.
|
||||
public enum WinReason: String, Hashable, Sendable {
|
||||
case checkmate
|
||||
/// Checkers: the side to move has no legal move, which includes having no pieces left.
|
||||
case noMoves = "no-moves"
|
||||
/// Reversi: neither side can move and the winner has more discs.
|
||||
case moreDiscs = "more-discs"
|
||||
case threeInARow = "three-in-a-row"
|
||||
}
|
||||
|
||||
/// Why a game was drawn. The raw values are the names used in `games-v1.json`.
|
||||
public enum DrawReason: String, Hashable, Sendable {
|
||||
case stalemate
|
||||
case insufficientMaterial = "insufficient-material"
|
||||
/// Chess: 100 plies without a capture or a pawn move.
|
||||
case fiftyMoves = "fifty-moves"
|
||||
/// Checkers: 80 plies without a capture or a man move.
|
||||
case fortyMoves = "forty-moves"
|
||||
/// The same position for the third time.
|
||||
case repetition
|
||||
/// Reversi: neither side can move and the discs are level.
|
||||
case equalDiscs = "equal-discs"
|
||||
case fullBoard = "full-board"
|
||||
}
|
||||
|
||||
/// How a position stands. Resigning and agreed draws travel on the wire; only the natural ends
|
||||
/// that both sides compute from the log are here.
|
||||
public enum Outcome: Hashable, Sendable {
|
||||
case ongoing
|
||||
case win(Side, WinReason)
|
||||
case draw(DrawReason)
|
||||
|
||||
public var isOver: Bool { self != .ongoing }
|
||||
}
|
||||
|
||||
/// Why a move was refused.
|
||||
public enum MoveError: Error, Hashable, Sendable {
|
||||
/// Not written in the game's notation, for example `E2E4` or `11 - 15`.
|
||||
case malformed
|
||||
/// Written correctly but not legal here. Every move is illegal once the game is over.
|
||||
case illegal
|
||||
}
|
||||
|
||||
/// Why a log could not be replayed.
|
||||
public enum ReplayError: Error, Hashable, Sendable {
|
||||
/// The log is longer than `GameType.maxPlies` allows.
|
||||
case tooLong(plies: Int, max: Int)
|
||||
/// The entry at this zero-based index of the log was refused.
|
||||
case atPly(Int, MoveError)
|
||||
}
|
||||
|
||||
/// What a piece is. Checkers uses `man` and `king`, reversi `disc`, tic-tac-toe `mark`.
|
||||
public enum PieceKind: String, Hashable, Sendable {
|
||||
case pawn
|
||||
case knight
|
||||
case bishop
|
||||
case rook
|
||||
case queen
|
||||
case king
|
||||
case man
|
||||
case disc
|
||||
case mark
|
||||
}
|
||||
|
||||
/// One piece on the board.
|
||||
public struct Piece: Hashable, Sendable {
|
||||
public let side: Side
|
||||
public let kind: PieceKind
|
||||
|
||||
public init(side: Side, kind: PieceKind) {
|
||||
self.side = side
|
||||
self.kind = kind
|
||||
}
|
||||
}
|
||||
|
||||
/// A board for drawing. Row 0 is the top row as the first player sees the board: chess has rank
|
||||
/// 8 at the top, checkers has squares 29-32 there (dark's men start at the bottom), reversi has
|
||||
/// rank 1 there, tic-tac-toe has cells 1-3. Turn it round to show the second player's view.
|
||||
public struct Board: Hashable, Sendable {
|
||||
public let rows: Int
|
||||
public let columns: Int
|
||||
/// Row-major from the top-left.
|
||||
public let cells: [Piece?]
|
||||
/// The wire name of each cell (`e4`, `15`, `d3`, `5`), nil where nothing can stand.
|
||||
public let squares: [String?]
|
||||
|
||||
public func piece(row: Int, column: Int) -> Piece? {
|
||||
cells[row * columns + column]
|
||||
}
|
||||
|
||||
public func square(row: Int, column: Int) -> String? {
|
||||
squares[row * columns + column]
|
||||
}
|
||||
}
|
||||
|
||||
/// A position in one of the four games. Values are immutable; applying a move makes a new one.
|
||||
public enum GamePosition: Hashable, Sendable {
|
||||
case chess(ChessPosition)
|
||||
case checkers(CheckersPosition)
|
||||
case reversi(ReversiPosition)
|
||||
case tictactoe(TicTacToePosition)
|
||||
|
||||
public var type: GameType {
|
||||
switch self {
|
||||
case .chess: return .chess
|
||||
case .checkers: return .checkers
|
||||
case .reversi: return .reversi
|
||||
case .tictactoe: return .tictactoe
|
||||
}
|
||||
}
|
||||
|
||||
public var sideToMove: Side { rules.sideToMove }
|
||||
|
||||
public var board: Board { rules.board }
|
||||
|
||||
/// This position in its game's setup notation; see `Games.position(_:setup:)`.
|
||||
public var setup: String { rules.setup }
|
||||
|
||||
var rules: any GameRules {
|
||||
switch self {
|
||||
case .chess(let p): return p
|
||||
case .checkers(let p): return p
|
||||
case .reversi(let p): return p
|
||||
case .tictactoe(let p): return p
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The rules engine for the chat games.
|
||||
///
|
||||
/// Moves are strings in the wire notation of DESIGN.md section 2. `apply` accepts exactly the
|
||||
/// strings `legalMoves` returns, so a log that one app wrote replays identically on the other.
|
||||
public enum Games {
|
||||
public static func start(_ type: GameType) -> GamePosition {
|
||||
switch type {
|
||||
case .chess: return .chess(.start)
|
||||
case .checkers: return .checkers(.start)
|
||||
case .reversi: return .reversi(.start)
|
||||
case .tictactoe: return .tictactoe(.start)
|
||||
}
|
||||
}
|
||||
|
||||
/// A position from a setup string, for tests and tools; the wire never carries one.
|
||||
///
|
||||
/// Chess takes FEN. Checkers takes PDN FEN such as `B:W21,22:BK1,9` (B is the first, dark
|
||||
/// side; K marks a king). Reversi takes eight rows of `x` (first), `o` and `-` from rank 1
|
||||
/// to rank 8 split by `/`, a space and the side to move, `x` or `o`; tic-tac-toe the same
|
||||
/// with three rows of three. Returns nil for anything malformed or impossible.
|
||||
public static func position(_ type: GameType, setup: String) -> GamePosition? {
|
||||
switch type {
|
||||
case .chess: return ChessPosition(setup: setup).map { .chess($0) }
|
||||
case .checkers: return CheckersPosition(setup: setup).map { .checkers($0) }
|
||||
case .reversi: return ReversiPosition(setup: setup).map { .reversi($0) }
|
||||
case .tictactoe: return TicTacToePosition(setup: setup).map { .tictactoe($0) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Every legal move, empty once the game is over.
|
||||
public static func legalMoves(_ position: GamePosition) -> [String] {
|
||||
position.rules.legalNotations()
|
||||
}
|
||||
|
||||
public static func apply(_ position: GamePosition, _ move: String) throws(MoveError)
|
||||
-> GamePosition
|
||||
{
|
||||
switch position {
|
||||
case .chess(let p): return .chess(try p.applying(notation: move))
|
||||
case .checkers(let p): return .checkers(try p.applying(notation: move))
|
||||
case .reversi(let p): return .reversi(try p.applying(notation: move))
|
||||
case .tictactoe(let p): return .tictactoe(try p.applying(notation: move))
|
||||
}
|
||||
}
|
||||
|
||||
public static func outcome(_ position: GamePosition) -> Outcome {
|
||||
position.rules.outcome
|
||||
}
|
||||
|
||||
/// The position after a whole log played from the start.
|
||||
public static func replay(_ type: GameType, _ log: [String]) throws(ReplayError)
|
||||
-> GamePosition
|
||||
{
|
||||
guard log.count <= type.maxPlies else {
|
||||
throw .tooLong(plies: log.count, max: type.maxPlies)
|
||||
}
|
||||
return try replay(from: start(type), log)
|
||||
}
|
||||
|
||||
/// The position after `moves` played from `position`. An error names the index in `moves`.
|
||||
public static func replay(from position: GamePosition, _ moves: [String]) throws(ReplayError)
|
||||
-> GamePosition
|
||||
{
|
||||
var current = position
|
||||
for (index, move) in moves.enumerated() {
|
||||
do {
|
||||
current = try apply(current, move)
|
||||
} catch {
|
||||
throw .atPly(index, error)
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
/// The number of move sequences of length `depth`, for checking move generation.
|
||||
public static func perft(_ position: GamePosition, depth: Int) -> Int {
|
||||
position.rules.perft(depth)
|
||||
}
|
||||
}
|
||||
|
||||
/// What each game's position type provides to `Games`.
|
||||
protocol GameRules: Hashable, Sendable {
|
||||
associatedtype Move
|
||||
|
||||
var sideToMove: Side { get }
|
||||
var board: Board { get }
|
||||
var setup: String { get }
|
||||
var outcome: Outcome { get }
|
||||
|
||||
/// The legal moves, empty once the game is over.
|
||||
func legalMoveList() -> [Move]
|
||||
func notation(_ move: Move) -> String
|
||||
func applying(_ move: Move) -> Self
|
||||
/// Whether `text` is written in the game's notation, legal or not.
|
||||
static func isWellFormed(_ text: String) -> Bool
|
||||
}
|
||||
|
||||
extension GameRules {
|
||||
func legalNotations() -> [String] {
|
||||
legalMoveList().map(notation)
|
||||
}
|
||||
|
||||
func applying(notation text: String) throws(MoveError) -> Self {
|
||||
guard Self.isWellFormed(text) else { throw .malformed }
|
||||
for move in legalMoveList() where notation(move) == text {
|
||||
return applying(move)
|
||||
}
|
||||
throw .illegal
|
||||
}
|
||||
|
||||
func perft(_ depth: Int) -> Int {
|
||||
if depth <= 0 { return 1 }
|
||||
let moves = legalMoveList()
|
||||
if depth == 1 { return moves.count }
|
||||
var total = 0
|
||||
for move in moves {
|
||||
total += applying(move).perft(depth - 1)
|
||||
}
|
||||
return total
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/// A reversi position. Files a-h run left to right and ranks 1-8 top to bottom as the first
|
||||
/// player draws the board.
|
||||
public struct ReversiPosition: Hashable, Sendable {
|
||||
/// Index is row * 8 + column: row 0 is rank 1, column 0 is file a. First's (black) discs
|
||||
/// are 1, second's (white) -1.
|
||||
let squares: [Int8]
|
||||
public let sideToMove: Side
|
||||
|
||||
/// A square index, or `pass`.
|
||||
typealias Move = Int
|
||||
static let pass: Move = -1
|
||||
|
||||
static let start = ReversiPosition(
|
||||
setup: "--------/--------/--------/---ox---/---xo---/--------/--------/-------- x")!
|
||||
|
||||
var sign: Int8 { sideToMove == .first ? 1 : -1 }
|
||||
}
|
||||
|
||||
extension ReversiPosition: GameRules {
|
||||
var outcome: Outcome {
|
||||
if !legalMoveList().isEmpty { return .ongoing }
|
||||
let first = squares.filter { $0 == 1 }.count
|
||||
let second = squares.filter { $0 == -1 }.count
|
||||
if first == second { return .draw(.equalDiscs) }
|
||||
return .win(first > second ? .first : .second, .moreDiscs)
|
||||
}
|
||||
|
||||
/// Placements, or `pass` only when there are none and the opponent has one. Empty when
|
||||
/// neither side can place, which ends the game.
|
||||
func legalMoveList() -> [Move] {
|
||||
let mine = ReversiRules.placements(squares, sign)
|
||||
if !mine.isEmpty { return mine }
|
||||
return ReversiRules.placements(squares, -sign).isEmpty ? [] : [Self.pass]
|
||||
}
|
||||
|
||||
func notation(_ move: Move) -> String {
|
||||
move == Self.pass ? "pass" : ReversiRules.name(move)
|
||||
}
|
||||
|
||||
func applying(_ move: Move) -> ReversiPosition {
|
||||
guard move != Self.pass else {
|
||||
return ReversiPosition(squares: squares, sideToMove: sideToMove.opponent)
|
||||
}
|
||||
var board = squares
|
||||
board[move] = sign
|
||||
for flipped in ReversiRules.flips(squares, move, sign) {
|
||||
board[flipped] = sign
|
||||
}
|
||||
return ReversiPosition(squares: board, sideToMove: sideToMove.opponent)
|
||||
}
|
||||
|
||||
static func isWellFormed(_ text: String) -> Bool {
|
||||
text == "pass" || ReversiRules.square(named: text) != nil
|
||||
}
|
||||
|
||||
var board: Board {
|
||||
Board(
|
||||
rows: 8, columns: 8,
|
||||
cells: squares.map {
|
||||
$0 == 0 ? nil : Piece(side: $0 > 0 ? .first : .second, kind: .disc)
|
||||
},
|
||||
squares: (0..<64).map { ReversiRules.name($0) })
|
||||
}
|
||||
|
||||
var setup: String {
|
||||
let rows = (0..<8).map { row in
|
||||
String(squares[row * 8..<row * 8 + 8].map { $0 == 1 ? "x" : $0 == -1 ? "o" : "-" })
|
||||
}
|
||||
return rows.joined(separator: "/") + (sideToMove == .first ? " x" : " o")
|
||||
}
|
||||
|
||||
/// Reads eight rows of `x`, `o` and `-` from rank 1 to rank 8 split by `/`, a space, and
|
||||
/// the side to move.
|
||||
init?(setup: String) {
|
||||
let fields = setup.split(separator: " ", omittingEmptySubsequences: false)
|
||||
guard fields.count == 2 else { return nil }
|
||||
let rows = fields[0].split(separator: "/", omittingEmptySubsequences: false)
|
||||
guard rows.count == 8, rows.allSatisfy({ $0.count == 8 }) else { return nil }
|
||||
var board: [Int8] = []
|
||||
for character in rows.joined() {
|
||||
switch character {
|
||||
case "x": board.append(1)
|
||||
case "o": board.append(-1)
|
||||
case "-": board.append(0)
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
switch fields[1] {
|
||||
case "x": self.init(squares: board, sideToMove: .first)
|
||||
case "o": self.init(squares: board, sideToMove: .second)
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ReversiRules {
|
||||
/// (row, column) steps.
|
||||
static let steps = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]
|
||||
|
||||
static func name(_ index: Int) -> String {
|
||||
let file = Character(UnicodeScalar(UInt8(ascii: "a") + UInt8(index % 8)))
|
||||
return String(file) + String(index / 8 + 1)
|
||||
}
|
||||
|
||||
static func square(named text: String) -> Int? {
|
||||
let bytes = Array(text.utf8)
|
||||
guard bytes.count == 2,
|
||||
(UInt8(ascii: "a")...UInt8(ascii: "h")).contains(bytes[0]),
|
||||
(UInt8(ascii: "1")...UInt8(ascii: "8")).contains(bytes[1])
|
||||
else { return nil }
|
||||
return Int(bytes[1] - UInt8(ascii: "1")) * 8 + Int(bytes[0] - UInt8(ascii: "a"))
|
||||
}
|
||||
|
||||
/// The discs a placement on `index` by the side with `sign` would turn over.
|
||||
static func flips(_ board: [Int8], _ index: Int, _ sign: Int8) -> [Int] {
|
||||
guard board[index] == 0 else { return [] }
|
||||
var turned: [Int] = []
|
||||
for (dr, dc) in steps {
|
||||
var row = index / 8 + dr
|
||||
var column = index % 8 + dc
|
||||
var line: [Int] = []
|
||||
while row >= 0 && row < 8 && column >= 0 && column < 8
|
||||
&& board[row * 8 + column] == -sign
|
||||
{
|
||||
line.append(row * 8 + column)
|
||||
row += dr
|
||||
column += dc
|
||||
}
|
||||
if !line.isEmpty && row >= 0 && row < 8 && column >= 0 && column < 8
|
||||
&& board[row * 8 + column] == sign
|
||||
{
|
||||
turned += line
|
||||
}
|
||||
}
|
||||
return turned
|
||||
}
|
||||
|
||||
static func placements(_ board: [Int8], _ sign: Int8) -> [Int] {
|
||||
(0..<64).filter { !flips(board, $0, sign).isEmpty }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/// A tic-tac-toe position. Cells are named 1-9 row by row from the top-left; X moves first.
|
||||
public struct TicTacToePosition: Hashable, Sendable {
|
||||
/// Cell 0 is `1`. X (first) is 1, O (second) is -1.
|
||||
let cells: [Int8]
|
||||
public let sideToMove: Side
|
||||
|
||||
/// A cell index.
|
||||
typealias Move = Int
|
||||
|
||||
static let start = TicTacToePosition(cells: [Int8](repeating: 0, count: 9), sideToMove: .first)
|
||||
|
||||
static let lines = [
|
||||
[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6],
|
||||
]
|
||||
|
||||
var winner: Side? {
|
||||
for line in Self.lines where cells[line[0]] != 0 {
|
||||
if cells[line[0]] == cells[line[1]] && cells[line[1]] == cells[line[2]] {
|
||||
return cells[line[0]] > 0 ? .first : .second
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension TicTacToePosition: GameRules {
|
||||
var outcome: Outcome {
|
||||
if let winner { return .win(winner, .threeInARow) }
|
||||
return cells.contains(0) ? .ongoing : .draw(.fullBoard)
|
||||
}
|
||||
|
||||
func legalMoveList() -> [Move] {
|
||||
winner != nil ? [] : (0..<9).filter { cells[$0] == 0 }
|
||||
}
|
||||
|
||||
func notation(_ move: Move) -> String {
|
||||
String(move + 1)
|
||||
}
|
||||
|
||||
func applying(_ move: Move) -> TicTacToePosition {
|
||||
var next = cells
|
||||
next[move] = sideToMove == .first ? 1 : -1
|
||||
return TicTacToePosition(cells: next, sideToMove: sideToMove.opponent)
|
||||
}
|
||||
|
||||
static func isWellFormed(_ text: String) -> Bool {
|
||||
text.utf8.count == 1 && (UInt8(ascii: "1")...UInt8(ascii: "9")).contains(text.utf8.first!)
|
||||
}
|
||||
|
||||
var board: Board {
|
||||
Board(
|
||||
rows: 3, columns: 3,
|
||||
cells: cells.map {
|
||||
$0 == 0 ? nil : Piece(side: $0 > 0 ? .first : .second, kind: .mark)
|
||||
},
|
||||
squares: (1...9).map { String($0) })
|
||||
}
|
||||
|
||||
var setup: String {
|
||||
let rows = (0..<3).map { row in
|
||||
String(cells[row * 3..<row * 3 + 3].map { $0 == 1 ? "x" : $0 == -1 ? "o" : "-" })
|
||||
}
|
||||
return rows.joined(separator: "/") + (sideToMove == .first ? " x" : " o")
|
||||
}
|
||||
|
||||
/// Reads three rows of `x`, `o` and `-` split by `/`, a space, and the side to move, which
|
||||
/// must be the one the counts say; a board where both sides have a line is refused.
|
||||
init?(setup: String) {
|
||||
let fields = setup.split(separator: " ", omittingEmptySubsequences: false)
|
||||
guard fields.count == 2 else { return nil }
|
||||
let rows = fields[0].split(separator: "/", omittingEmptySubsequences: false)
|
||||
guard rows.count == 3, rows.allSatisfy({ $0.count == 3 }) else { return nil }
|
||||
var cells: [Int8] = []
|
||||
for character in rows.joined() {
|
||||
switch character {
|
||||
case "x": cells.append(1)
|
||||
case "o": cells.append(-1)
|
||||
case "-": cells.append(0)
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
let xs = cells.filter { $0 == 1 }.count
|
||||
let os = cells.filter { $0 == -1 }.count
|
||||
let side: Side
|
||||
switch (fields[1], xs - os) {
|
||||
case ("x", 0): side = .first
|
||||
case ("o", 1): side = .second
|
||||
default: return nil
|
||||
}
|
||||
let lines = Self.lines.filter { line in
|
||||
cells[line[0]] != 0 && cells[line[0]] == cells[line[1]]
|
||||
&& cells[line[1]] == cells[line[2]]
|
||||
}
|
||||
guard Set(lines.map { cells[$0[0]] }).count < 2 else { return nil }
|
||||
self.init(cells: cells, sideToMove: side)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import FlamenetGames
|
||||
import XCTest
|
||||
|
||||
/// The parts of the API the vectors do not reach: names, caps, board orientation, setup refusals.
|
||||
final class GamesTests: XCTestCase {
|
||||
func testWireNames() {
|
||||
XCTAssertEqual(
|
||||
GameType.allCases.map(\.rawValue), ["chess", "checkers", "reversi", "tictactoe"])
|
||||
XCTAssertEqual(Side.allCases.map(\.rawValue), ["first", "second"])
|
||||
XCTAssertEqual(GameType.allCases.map(\.maxPlies), [600, 400, 130, 9])
|
||||
}
|
||||
|
||||
func testPliesAlternateFromFirst() {
|
||||
for type in GameType.allCases {
|
||||
XCTAssertEqual(
|
||||
(0..<4).map { type.side(ofPly: $0) }, [.first, .second, .first, .second])
|
||||
}
|
||||
}
|
||||
|
||||
func testTheCapIsCheckedBeforeAnyMove() {
|
||||
XCTAssertThrowsError(try Games.replay(.tictactoe, Array(repeating: "x", count: 10))) {
|
||||
XCTAssertEqual($0 as? ReplayError, .tooLong(plies: 10, max: 9))
|
||||
}
|
||||
}
|
||||
|
||||
func testApplyReportsWhichKindOfRefusal() throws {
|
||||
let start = Games.start(.chess)
|
||||
XCTAssertThrowsError(try Games.apply(start, "e2e5")) {
|
||||
XCTAssertEqual($0 as? MoveError, .illegal)
|
||||
}
|
||||
XCTAssertThrowsError(try Games.apply(start, "E2E4")) {
|
||||
XCTAssertEqual($0 as? MoveError, .malformed)
|
||||
}
|
||||
let moved = try Games.apply(start, "e2e4")
|
||||
XCTAssertEqual(start, Games.start(.chess), "applying a move left the old position alone")
|
||||
XCTAssertEqual(moved, try Games.replay(.chess, ["e2e4"]))
|
||||
XCTAssertEqual(moved.sideToMove, .second)
|
||||
}
|
||||
|
||||
func testChessBoardHasRankEightOnTop() throws {
|
||||
let board = Games.start(.chess).board
|
||||
XCTAssertEqual(board.rows, 8)
|
||||
XCTAssertEqual(board.square(row: 0, column: 0), "a8")
|
||||
XCTAssertEqual(board.piece(row: 0, column: 0), Piece(side: .second, kind: .rook))
|
||||
XCTAssertEqual(board.square(row: 7, column: 4), "e1")
|
||||
XCTAssertEqual(board.piece(row: 7, column: 4), Piece(side: .first, kind: .king))
|
||||
XCTAssertEqual(board.cells.compactMap { $0 }.count, 32)
|
||||
}
|
||||
|
||||
func testChessReportsCheck() throws {
|
||||
let mated = try Games.replay(.chess, ["f2f3", "e7e5", "g2g4", "d8h4"])
|
||||
guard case .chess(let position) = mated else { return XCTFail("not chess") }
|
||||
XCTAssertTrue(position.inCheck)
|
||||
}
|
||||
|
||||
func testCheckersBoardHasTheFirstPlayersMenAtTheBottom() {
|
||||
let board = Games.start(.checkers).board
|
||||
XCTAssertEqual(
|
||||
(0..<8).map { board.square(row: 7, column: $0) },
|
||||
["4", nil, "3", nil, "2", nil, "1", nil])
|
||||
XCTAssertEqual(
|
||||
(0..<8).map { board.square(row: 0, column: $0) },
|
||||
[nil, "32", nil, "31", nil, "30", nil, "29"])
|
||||
XCTAssertEqual(board.piece(row: 7, column: 0), Piece(side: .first, kind: .man))
|
||||
XCTAssertEqual(board.piece(row: 0, column: 1), Piece(side: .second, kind: .man))
|
||||
XCTAssertNil(board.piece(row: 4, column: 3))
|
||||
}
|
||||
|
||||
func testReversiBoardHasRankOneOnTop() {
|
||||
let board = Games.start(.reversi).board
|
||||
XCTAssertEqual(board.square(row: 0, column: 0), "a1")
|
||||
XCTAssertEqual(board.square(row: 3, column: 3), "d4")
|
||||
XCTAssertEqual(board.piece(row: 3, column: 3), Piece(side: .second, kind: .disc))
|
||||
XCTAssertEqual(board.piece(row: 3, column: 4), Piece(side: .first, kind: .disc))
|
||||
XCTAssertEqual(board.piece(row: 4, column: 3), Piece(side: .first, kind: .disc))
|
||||
XCTAssertEqual(board.piece(row: 4, column: 4), Piece(side: .second, kind: .disc))
|
||||
}
|
||||
|
||||
func testTicTacToeBoardIsNumberedRowByRow() throws {
|
||||
let board = try Games.replay(.tictactoe, ["5", "1"]).board
|
||||
XCTAssertEqual(board.squares, ["1", "2", "3", "4", "5", "6", "7", "8", "9"])
|
||||
XCTAssertEqual(board.piece(row: 1, column: 1), Piece(side: .first, kind: .mark))
|
||||
XCTAssertEqual(board.piece(row: 0, column: 0), Piece(side: .second, kind: .mark))
|
||||
}
|
||||
|
||||
func testImpossibleSetupsAreRefused() {
|
||||
let refused: [(GameType, String)] = [
|
||||
(.chess, "4k3/8/8/8/8/8/8/4K2K w - - 0 1"), // two white kings
|
||||
(.chess, "4k2P/8/8/8/8/8/8/4K3 w - - 0 1"), // a pawn on the last rank
|
||||
(.chess, "4k3/8/8/8/8/8/8/4K2R w - - 0 1 extra"),
|
||||
(.chess, "4k3/4R3/8/8/8/8/8/4K3 w - - 0 1"), // black in check with white to move
|
||||
(.chess, "4k3/8/8/8/8/8/8/4K3 w - e6 0 1"), // en passant with no pawn that stepped
|
||||
(.chess, "4k3/8/8/8/8/8/8/4K3 w KK - 0 1"),
|
||||
(.checkers, "B:W21:B29"), // a dark man where it would have been crowned
|
||||
(.checkers, "B:W21,21:B1"),
|
||||
(.checkers, "B:W33:B1"),
|
||||
(.checkers, "B:W21:W1"),
|
||||
(.reversi, "--------/--------/--------/---ox---/---xo---/--------/--------/------- x"),
|
||||
(.reversi, "--------/--------/--------/---ox---/---xo---/--------/--------/-------- b"),
|
||||
(.tictactoe, "x--/---/--- x"), // X has moved, so O is to move
|
||||
(.tictactoe, "xxx/ooo/--- x"),
|
||||
]
|
||||
for (type, setup) in refused {
|
||||
XCTAssertNil(Games.position(type, setup: setup), setup)
|
||||
}
|
||||
}
|
||||
|
||||
func testSetupReadsBackTheStart() {
|
||||
for type in GameType.allCases {
|
||||
let start = Games.start(type)
|
||||
XCTAssertEqual(Games.position(type, setup: start.setup), start, type.rawValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import FlamenetGames
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
/// Runs `games-v1.json`. The Android `:games` module runs the same file, byte for byte, so the
|
||||
/// two engines agree on every vector or one of the suites is red.
|
||||
final class VectorTests: XCTestCase {
|
||||
struct Vectors: Decodable {
|
||||
let v: Int
|
||||
let perft: [Perft]
|
||||
let cases: [Case]
|
||||
}
|
||||
|
||||
struct Perft: Decodable {
|
||||
let name: String
|
||||
let t: String
|
||||
let setup: String?
|
||||
let counts: [Int]
|
||||
let deep: [[Int]]?
|
||||
}
|
||||
|
||||
struct Case: Decodable {
|
||||
let name: String
|
||||
let t: String
|
||||
let setup: String?
|
||||
let log: [String]
|
||||
let expect: Expect
|
||||
}
|
||||
|
||||
struct Expect: Decodable {
|
||||
let error: String?
|
||||
let ply: Int?
|
||||
let outcome: String?
|
||||
let toMove: String?
|
||||
let legal: [String]?
|
||||
let includes: [String]?
|
||||
let excludes: [String]?
|
||||
let setup: String?
|
||||
let trace: [Int]?
|
||||
}
|
||||
|
||||
func loadVectors() throws -> Vectors {
|
||||
let url = try XCTUnwrap(Bundle.module.url(forResource: "games-v1", withExtension: "json"))
|
||||
return try JSONDecoder().decode(Vectors.self, from: Data(contentsOf: url))
|
||||
}
|
||||
|
||||
func testPerftMatchesTheLiterature() throws {
|
||||
for entry in try loadVectors().perft {
|
||||
let position = try startingPosition(entry.t, entry.setup)
|
||||
for (index, expected) in entry.counts.enumerated() {
|
||||
assertPerft(entry.name, position, depth: index + 1, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The deeper published counts take seconds to minutes, so they run only on request.
|
||||
func testDeepPerft() throws {
|
||||
guard ProcessInfo.processInfo.environment["FLAMENET_GAMES_DEEP"] == "1" else {
|
||||
throw XCTSkip("set FLAMENET_GAMES_DEEP=1 to run the deep perft counts")
|
||||
}
|
||||
for entry in try loadVectors().perft {
|
||||
let position = try startingPosition(entry.t, entry.setup)
|
||||
for pair in entry.deep ?? [] {
|
||||
assertPerft(entry.name, position, depth: pair[0], pair[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testEveryCase() throws {
|
||||
let cases = try loadVectors().cases
|
||||
for vector in cases {
|
||||
check(vector)
|
||||
}
|
||||
print("games-v1.json: \(cases.count) cases checked")
|
||||
}
|
||||
|
||||
/// Guards against a vectors file that lost entries and still parses.
|
||||
func testTheVectorsCoverEveryGame() throws {
|
||||
let vectors = try loadVectors()
|
||||
XCTAssertEqual(vectors.v, 1)
|
||||
XCTAssertGreaterThanOrEqual(vectors.cases.count, 40)
|
||||
for type in GameType.allCases {
|
||||
XCTAssertGreaterThanOrEqual(
|
||||
vectors.cases.filter { $0.t == type.rawValue }.count, 10, type.rawValue)
|
||||
XCTAssertTrue(vectors.perft.contains { $0.t == type.rawValue }, type.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
private func assertPerft(_ name: String, _ position: GamePosition, depth: Int, _ expected: Int)
|
||||
{
|
||||
let clock = ContinuousClock()
|
||||
var count = 0
|
||||
let elapsed = clock.measure { count = Games.perft(position, depth: depth) }
|
||||
print("perft \(name) depth \(depth): \(count) in \(elapsed)")
|
||||
XCTAssertEqual(count, expected, "perft \(name) depth \(depth)")
|
||||
}
|
||||
|
||||
private func startingPosition(_ name: String, _ setup: String?) throws -> GamePosition {
|
||||
let type = try XCTUnwrap(GameType(rawValue: name), "no game called \(name)")
|
||||
guard let setup else { return Games.start(type) }
|
||||
return try XCTUnwrap(Games.position(type, setup: setup), "setup refused: \(setup)")
|
||||
}
|
||||
|
||||
private func check(_ vector: Case) {
|
||||
let name = vector.name
|
||||
guard let start = try? startingPosition(vector.t, vector.setup),
|
||||
let type = GameType(rawValue: vector.t)
|
||||
else { return XCTFail("\(name): cannot start") }
|
||||
let expect = vector.expect
|
||||
|
||||
let result: Result<GamePosition, ReplayError>
|
||||
do throws(ReplayError) {
|
||||
result = .success(
|
||||
vector.setup == nil
|
||||
? try Games.replay(type, vector.log) : try Games.replay(from: start, vector.log)
|
||||
)
|
||||
} catch {
|
||||
result = .failure(error)
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .failure(let error):
|
||||
XCTAssertEqual(describe(error), expectedError(expect), name)
|
||||
case .success(let position):
|
||||
XCTAssertNil(expect.error, "\(name): played through, expected \(expectedError(expect))")
|
||||
if let outcome = expect.outcome {
|
||||
XCTAssertEqual(describe(Games.outcome(position)), outcome, name)
|
||||
}
|
||||
if let toMove = expect.toMove {
|
||||
XCTAssertEqual(position.sideToMove.rawValue, toMove, name)
|
||||
}
|
||||
let legal = Games.legalMoves(position)
|
||||
if let exact = expect.legal {
|
||||
XCTAssertEqual(Set(legal), Set(exact), name)
|
||||
XCTAssertEqual(legal.count, exact.count, "\(name): duplicate moves")
|
||||
}
|
||||
for move in expect.includes ?? [] {
|
||||
XCTAssertTrue(legal.contains(move), "\(name): \(move) should be legal")
|
||||
}
|
||||
for move in expect.excludes ?? [] {
|
||||
XCTAssertFalse(legal.contains(move), "\(name): \(move) should not be legal")
|
||||
}
|
||||
if let setup = expect.setup {
|
||||
XCTAssertEqual(position.setup, setup, name)
|
||||
}
|
||||
}
|
||||
walk(vector, from: start)
|
||||
}
|
||||
|
||||
/// Plays the log one move at a time and checks what must hold at every step: the game is over
|
||||
/// exactly when there are no legal moves, `apply` accepts exactly the listed moves, the side
|
||||
/// to move is the one `side(ofPly:)` names, the setup string reads back unchanged, and the
|
||||
/// number of legal moves matches `trace` when the vector has one.
|
||||
private func walk(_ vector: Case, from start: GamePosition) {
|
||||
let name = vector.name
|
||||
let trace = vector.expect.trace
|
||||
if let trace {
|
||||
XCTAssertEqual(trace.count, vector.log.count + 1, "\(name): trace length")
|
||||
}
|
||||
var position = start
|
||||
for (index, move) in vector.log.enumerated() {
|
||||
let legal = Games.legalMoves(position)
|
||||
if let trace, index < trace.count {
|
||||
XCTAssertEqual(
|
||||
legal.count, trace[index], "\(name): legal moves before ply \(index)")
|
||||
}
|
||||
XCTAssertEqual(
|
||||
Games.outcome(position).isOver, legal.isEmpty, "\(name): before ply \(index)")
|
||||
if vector.setup == nil {
|
||||
XCTAssertEqual(position.sideToMove, position.type.side(ofPly: index), name)
|
||||
}
|
||||
XCTAssertEqual(
|
||||
Games.position(position.type, setup: position.setup)?.setup, position.setup,
|
||||
"\(name): setup round trip before ply \(index)")
|
||||
do {
|
||||
position = try Games.apply(position, move)
|
||||
XCTAssertTrue(legal.contains(move), "\(name): applied \(move), not listed")
|
||||
} catch {
|
||||
XCTAssertFalse(legal.contains(move), "\(name): refused \(move), but listed")
|
||||
return
|
||||
}
|
||||
}
|
||||
XCTAssertEqual(
|
||||
Games.outcome(position).isOver, Games.legalMoves(position).isEmpty, "\(name): at end")
|
||||
if let trace, let last = trace.last {
|
||||
XCTAssertEqual(
|
||||
Games.legalMoves(position).count, last, "\(name): legal moves at the end")
|
||||
}
|
||||
}
|
||||
|
||||
private func describe(_ outcome: Outcome) -> String {
|
||||
switch outcome {
|
||||
case .ongoing: return "ongoing"
|
||||
case .win(let side, let reason): return "win \(side.rawValue) \(reason.rawValue)"
|
||||
case .draw(let reason): return "draw \(reason.rawValue)"
|
||||
}
|
||||
}
|
||||
|
||||
private func describe(_ error: ReplayError) -> String {
|
||||
switch error {
|
||||
case .tooLong: return "too-long"
|
||||
case .atPly(let ply, .malformed): return "malformed at \(ply)"
|
||||
case .atPly(let ply, .illegal): return "illegal at \(ply)"
|
||||
}
|
||||
}
|
||||
|
||||
private func expectedError(_ expect: Expect) -> String {
|
||||
guard let error = expect.error else { return "no error" }
|
||||
return error == "too-long" ? error : "\(error) at \(expect.ply ?? -1)"
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
breaks debug builds, which then never receive a token. -->
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<!-- Section 8.12: https://<relay>/add#... opens the app. The relay serves
|
||||
<!-- Section 8.14: https://<relay>/add#... opens the app. The relay serves
|
||||
the matching apple-app-site-association (team M5923Y84P5, /add*).
|
||||
The App ID needs the Associated Domains capability to sign. -->
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
|
||||
@@ -28,6 +28,16 @@
|
||||
<string>flamenet</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>net.flamenet.messenger.hello</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>flamenetmessenger</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"pack": "flamenet.retro",
|
||||
"name": "FlameNet",
|
||||
"fallback": "🙂",
|
||||
"stickers": [
|
||||
{ "id": "flame", "glyph": "🔥", "name": "Flame", "image": "flame.png" },
|
||||
{ "id": "heart", "glyph": "❤️", "name": "Heart", "image": "heart.png" },
|
||||
{ "id": "thumbs-up", "glyph": "👍", "name": "Thumbs up", "image": "thumbs-up.png" },
|
||||
{ "id": "lol", "glyph": "😂", "name": "Laughing", "image": "lol.png" },
|
||||
{ "id": "brb", "glyph": "🔙", "name": "Back soon", "image": "brb.png" },
|
||||
{ "id": "new", "glyph": "🆕", "name": "New", "image": "new.png" },
|
||||
{ "id": "star", "glyph": "⭐", "name": "Star", "image": "star.png" },
|
||||
{ "id": "trophy", "glyph": "🏆", "name": "Trophy", "image": "trophy.png" },
|
||||
{ "id": "cake", "glyph": "🎂", "name": "Birthday cake", "image": "cake.png" },
|
||||
{ "id": "balloon", "glyph": "🎈", "name": "Balloon", "image": "balloon.png" },
|
||||
{ "id": "gift", "glyph": "🎁", "name": "Gift", "image": "gift.png" },
|
||||
{ "id": "flowers", "glyph": "💐", "name": "Flowers", "image": "flowers.png" },
|
||||
{ "id": "love-letter", "glyph": "💌", "name": "Love letter", "image": "love-letter.png" },
|
||||
{ "id": "music", "glyph": "🎶", "name": "Music", "image": "music.png" },
|
||||
{ "id": "sun", "glyph": "☀️", "name": "Sun", "image": "sun.png" },
|
||||
{ "id": "moon", "glyph": "🌙", "name": "Moon", "image": "moon.png" },
|
||||
{ "id": "coffee", "glyph": "☕", "name": "Coffee", "image": "coffee.png" },
|
||||
{ "id": "pizza", "glyph": "🍕", "name": "Pizza", "image": "pizza.png" },
|
||||
{ "id": "candle", "glyph": "🕯️", "name": "Candle", "image": "candle.png" },
|
||||
{ "id": "hourglass", "glyph": "⏳", "name": "Hourglass", "image": "hourglass.png" },
|
||||
{ "id": "computer", "glyph": "🖥️", "name": "Computer", "image": "computer.png" },
|
||||
{ "id": "floppy", "glyph": "💾", "name": "Floppy disk", "image": "floppy.png" },
|
||||
{ "id": "construction", "glyph": "🚧", "name": "Under construction", "image": "construction.png" },
|
||||
{ "id": "paper-plane", "glyph": "✈️", "name": "Paper plane", "image": "paper-plane.png" }
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
@@ -53,7 +53,7 @@ final class AppEnvironment: ObservableObject {
|
||||
/// search box picks it up.
|
||||
@Published var pendingAddress: String?
|
||||
|
||||
/// Section 8.12: a contact link somebody opened, waiting for the person
|
||||
/// Section 8.14: a contact link somebody opened, waiting for the person
|
||||
/// to write a first message.
|
||||
@Published var pendingContactLink: ContactLink?
|
||||
|
||||
@@ -84,11 +84,25 @@ final class AppEnvironment: ObservableObject {
|
||||
init(store: SessionStore = SessionStore()) {
|
||||
self.store = store
|
||||
// A previously chosen site wins over the bundled default.
|
||||
let config = store.config ?? SiteConfig.default
|
||||
// The stored site, or the default; a DEBUG test launch is kept off
|
||||
// production (see `SiteConfig.forThisLaunch`).
|
||||
let config = SiteConfig.forThisLaunch(stored: store.config)
|
||||
self.phase = .signedOut(config)
|
||||
buildGraph(for: config)
|
||||
// Ask the server what it serves before anything reads the answer.
|
||||
Task { @MainActor in
|
||||
// DESIGN decision 47. An account signed in on this phone opens to
|
||||
// its chats from what is here and connects behind them, so a
|
||||
// launch with no network is not a sign-in screen. The site's
|
||||
// settings were settled and stored when it was first signed in.
|
||||
if let user = self.store.username, let pwd = self.store.appPassword,
|
||||
let userID = self.store.userID
|
||||
{
|
||||
await self.openFromPhone(
|
||||
userID: userID, username: user, appPassword: pwd,
|
||||
config: SiteConfig.forThisLaunch(stored: self.store.config))
|
||||
return
|
||||
}
|
||||
await self.resolveAccountProvider(for: config.baseURL)
|
||||
await self.detectCapabilities(for: config.baseURL)
|
||||
// Restore only after the probe, so the restored session is built on
|
||||
@@ -96,9 +110,11 @@ final class AppEnvironment: ObservableObject {
|
||||
guard let user = self.store.username, let pwd = self.store.appPassword else { return }
|
||||
let settled =
|
||||
self.phase.siteBaseURL == config.baseURL
|
||||
? (self.store.config ?? config) : config
|
||||
? SiteConfig.forThisLaunch(stored: self.store.config ?? config) : config
|
||||
if let auth = self.auth, await auth.restore(username: user, appPassword: pwd) {
|
||||
if let info = auth.currentUser {
|
||||
// Kept, so the next launch can open without the relay.
|
||||
self.store.userID = info.userID
|
||||
// Stand up the inbox BEFORE flipping phase, so the chat
|
||||
// view never sees a transient `e2eInbox == nil` and falls
|
||||
// back to the legacy nonce-scraping path.
|
||||
@@ -111,6 +127,54 @@ final class AppEnvironment: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Connecting behind a launch that opened from the phone.
|
||||
private var connectTask: Task<Void, Never>?
|
||||
|
||||
/// Open a signed-in account from the phone alone: the inbox from its own
|
||||
/// keys and history, the chats and the saved Today page with it, and then
|
||||
/// the relay in the background.
|
||||
private func openFromPhone(
|
||||
userID: Int, username: String, appPassword: String, config: SiteConfig
|
||||
) async {
|
||||
auth?.presentStoredCredential(username: username, appPassword: appPassword)
|
||||
await startE2E(
|
||||
userId: userID, username: username, appPassword: appPassword, config: config,
|
||||
connect: false)
|
||||
phase = .signedIn(config)
|
||||
connectTask?.cancel()
|
||||
connectTask = Task { [weak self] in
|
||||
await self?.connectInBackground(
|
||||
username: username, appPassword: appPassword, config: config)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the relay who this is until it answers, waiting longer each time up
|
||||
/// to a minute. An answer finishes connecting; a refusal of the credential
|
||||
/// is the one outcome that goes back to sign-in, as it did before.
|
||||
private func connectInBackground(
|
||||
username: String, appPassword: String, config: SiteConfig
|
||||
) async {
|
||||
var wait: UInt64 = 1
|
||||
while !Task.isCancelled {
|
||||
guard case .signedIn = phase, let auth else { return }
|
||||
switch await auth.restoreOutcome(username: username, appPassword: appPassword) {
|
||||
case .restored:
|
||||
if let info = auth.currentUser { store.userID = info.userID }
|
||||
await e2eInbox?.connect()
|
||||
// Names and anything else read from the account, now there.
|
||||
objectWillChange.send()
|
||||
return
|
||||
case .refused:
|
||||
stopE2E()
|
||||
phase = .signedOut(config)
|
||||
return
|
||||
case .unreachable:
|
||||
try? await Task.sleep(nanoseconds: wait * 1_000_000_000)
|
||||
wait = min(wait * 2, 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The one fact that belongs beside a screen's title: who you are, and
|
||||
/// where. The Android header has carried it since the restyle.
|
||||
var headerSubtitle: String? {
|
||||
@@ -192,7 +256,7 @@ final class AppEnvironment: ObservableObject {
|
||||
if let inbox = e2eInbox { Task { await inbox.rotateDeliveryKey() } }
|
||||
}
|
||||
|
||||
// MARK: - Contact links (section 8.12)
|
||||
// MARK: - Contact links (section 8.14)
|
||||
|
||||
/// Open a link: a contact link goes to the first-message sheet. Anything
|
||||
/// else is not ours and is ignored.
|
||||
@@ -281,15 +345,19 @@ final class AppEnvironment: ObservableObject {
|
||||
|
||||
/// Stand up the E2E inbox using the App-Password credential (HTTP Basic)
|
||||
/// the REST sign-in already established.
|
||||
private func startE2E(userId: Int, username: String, appPassword: String, config: SiteConfig)
|
||||
async
|
||||
{
|
||||
private func startE2E(
|
||||
userId: Int, username: String, appPassword: String, config: SiteConfig,
|
||||
connect: Bool = true
|
||||
) async {
|
||||
let client = E2EClient(
|
||||
siteBaseURL: config.baseURL, basicAuth: (username, appPassword),
|
||||
restPrefix: config.restPrefix)
|
||||
let store = KeychainE2EStore(userId: userId)
|
||||
// nil means we couldn't produce a usable signed prekey.
|
||||
guard let inbox = await E2EInbox.make(myUserId: userId, client: client, store: store) else {
|
||||
guard
|
||||
let inbox = await E2EInbox.make(
|
||||
myUserId: userId, client: client, store: store, connect: connect)
|
||||
else {
|
||||
return
|
||||
}
|
||||
// Our return address, before the first send.
|
||||
@@ -322,7 +390,7 @@ final class AppEnvironment: ObservableObject {
|
||||
inbox.restoreExtraHomes(restPrefix: config.restPrefix)
|
||||
inbox.start()
|
||||
self.e2eInbox = inbox
|
||||
// Section 8.12: an Unlisted choice made at sign-up, applied once the
|
||||
// Section 8.14: an Unlisted choice made at sign-up, applied once the
|
||||
// relay can take it.
|
||||
Task { await self.applyPendingListingChoice() }
|
||||
self.callManager = CallManager(inbox: inbox)
|
||||
@@ -721,6 +789,8 @@ final class AppEnvironment: ObservableObject {
|
||||
}
|
||||
|
||||
private func stopE2E() {
|
||||
connectTask?.cancel()
|
||||
connectTask = nil
|
||||
// Stop ringing before anything else goes.
|
||||
VoIPCallReceiver.shared.finish(reason: .failed)
|
||||
callManager?.teardown()
|
||||
@@ -749,6 +819,7 @@ final class AppEnvironment: ObservableObject {
|
||||
let info = try await auth.signIn(usernameOrEmail: usernameOrEmail, password: password)
|
||||
store.username = info.username
|
||||
store.appPassword = info.appPassword
|
||||
store.userID = info.userID
|
||||
await startE2E(
|
||||
userId: info.userID, username: info.username, appPassword: info.appPassword,
|
||||
config: config)
|
||||
@@ -779,6 +850,7 @@ final class AppEnvironment: ObservableObject {
|
||||
)
|
||||
store.username = info.username
|
||||
store.appPassword = info.appPassword
|
||||
store.userID = info.userID
|
||||
await startE2E(
|
||||
userId: info.userID, username: info.username, appPassword: info.appPassword,
|
||||
config: config)
|
||||
@@ -911,6 +983,7 @@ final class AppEnvironment: ObservableObject {
|
||||
|
||||
// 1.
|
||||
store.appPassword = nil
|
||||
store.userID = nil
|
||||
auth?.signOut()
|
||||
PushRegistrar.shared.signedOut()
|
||||
stopE2E()
|
||||
@@ -919,7 +992,11 @@ final class AppEnvironment: ObservableObject {
|
||||
filesIdentity = nil
|
||||
// Signing out keeps the chosen site: it is a property of the
|
||||
// installation, not of the session.
|
||||
phase = .signedOut(store.config ?? SiteConfig.default)
|
||||
phase = .signedOut(SiteConfig.forThisLaunch(stored: store.config))
|
||||
// The Today file and the weather picks go too, on every way out: the
|
||||
// sign-out screen, a device removed from the account, and a lost-device
|
||||
// wipe all come through here.
|
||||
TodayStore.forgetOnThisPhone()
|
||||
|
||||
guard let inbox else { return }
|
||||
Task { @MainActor in
|
||||
|
||||
@@ -61,6 +61,8 @@ struct FlamenetMessengerApp: App {
|
||||
@StateObject private var lock = AppLock()
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@AppStorage(SettingsKeys.appearance) private var appearanceRaw = AppearanceMode.system.rawValue
|
||||
/// What opening a hello link came to, when it needs saying.
|
||||
@State private var linkNotice: LinkNotice?
|
||||
|
||||
init() {
|
||||
// The Files package builds its own sessions; they follow the bridges.
|
||||
@@ -113,7 +115,25 @@ struct FlamenetMessengerApp: App {
|
||||
WindowGroup {
|
||||
// Open straight to the wallet, on a keypair generated in memory.
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.arguments.contains("-FNMSGFilesPreview") {
|
||||
if let index = ProcessInfo.processInfo.arguments.firstIndex(of: "-FNMSGKindsPreview")
|
||||
{
|
||||
// Cards, events and stickers with fixture data, for screenshots.
|
||||
let arguments = ProcessInfo.processInfo.arguments
|
||||
ChatKindsPreview(mode: index + 1 < arguments.count ? arguments[index + 1] : "thread")
|
||||
.tint(Theme.primary)
|
||||
} else if let index = ProcessInfo.processInfo.arguments.firstIndex(
|
||||
of: "-FNMSGStoriesPreview")
|
||||
{
|
||||
// The stories feed, take-down and caption screens with
|
||||
// fixture data, for screenshots.
|
||||
let arguments = ProcessInfo.processInfo.arguments
|
||||
StoriesPreview(mode: index + 1 < arguments.count ? arguments[index + 1] : "feed")
|
||||
.tint(Theme.primary)
|
||||
// The appearance setting, as the app proper applies
|
||||
// it, so a test can look at both.
|
||||
.preferredColorScheme(
|
||||
(AppearanceMode(rawValue: appearanceRaw) ?? .system).colorScheme)
|
||||
} else if ProcessInfo.processInfo.arguments.contains("-FNMSGFilesPreview") {
|
||||
// FilesView reads AppEnvironment for the signed-in handle
|
||||
// and the wallet request.
|
||||
FilesBrowserPreview()
|
||||
@@ -150,7 +170,7 @@ struct FlamenetMessengerApp: App {
|
||||
.preferredColorScheme(
|
||||
(AppearanceMode(rawValue: appearanceRaw) ?? .system).colorScheme
|
||||
)
|
||||
// Section 8.12: a contact link opened from outside the app.
|
||||
// Section 8.14: a contact link opened from outside the app.
|
||||
.onOpenURL { url in environment.open(url) }
|
||||
// The same link as a universal link (associated domains).
|
||||
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
|
||||
@@ -161,6 +181,19 @@ struct FlamenetMessengerApp: App {
|
||||
environment.deepLinkBuddyID = id
|
||||
}
|
||||
}
|
||||
// Hello links (SPEC §6.34), handed over by the relay's /hi page.
|
||||
.onOpenURL { url in openHelloLink(url) }
|
||||
.alert(
|
||||
linkNotice?.title ?? "",
|
||||
isPresented: Binding(
|
||||
get: { linkNotice != nil },
|
||||
set: { if !$0 { linkNotice = nil } }),
|
||||
presenting: linkNotice
|
||||
) { _ in
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: { notice in
|
||||
Text(notice.message)
|
||||
}
|
||||
// The cover goes OVER everything, including any sheet, and
|
||||
// ignores the safe area -- a lock screen that leaves the
|
||||
// navigation bar readable has not covered the screen.
|
||||
@@ -199,11 +232,40 @@ struct FlamenetMessengerApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
extension FlamenetMessengerApp {
|
||||
/// `flamenetmessenger://hi#<address>`: Home's add-by-address screen,
|
||||
/// filled in, and nothing more. See `HelloLink.landing`.
|
||||
fileprivate func openHelloLink(_ url: URL) {
|
||||
let signedIn: Bool
|
||||
if case .signedIn = environment.phase { signedIn = true } else { signedIn = false }
|
||||
let landing = HelloLink.landing(for: url, signedIn: signedIn)
|
||||
if let address = landing.address { environment.pendingAddress = address }
|
||||
linkNotice = landing.notice
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes between the two top-level phases.
|
||||
struct RootView: View {
|
||||
@EnvironmentObject private var environment: AppEnvironment
|
||||
|
||||
var body: some View {
|
||||
#if DEBUG
|
||||
// Screenshots of the Today tab, the directory and the hello link
|
||||
// card, with no account. See TodayScreensPreview.
|
||||
if let request = TodayScreensPreview.requested {
|
||||
TodayScreensPreview(request: request)
|
||||
} else if let seconds = CardOpensPreview.requested {
|
||||
// A sealed card opening on screen. See CardOpensPreview.
|
||||
CardOpensPreview(opensIn: seconds)
|
||||
} else {
|
||||
phases
|
||||
}
|
||||
#else
|
||||
phases
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder private var phases: some View {
|
||||
switch environment.phase {
|
||||
case .signedOut:
|
||||
SignInView()
|
||||
|
||||
@@ -92,6 +92,20 @@ final class NotificationManager {
|
||||
/// can tell "they have not seen this" from "they are reading it right now".
|
||||
static var visibleConversation: ConversationKey?
|
||||
|
||||
#if DEBUG
|
||||
/// Every message notice asked for, before any setting is consulted, so
|
||||
/// a test can prove what would have been said, and that an answer to an
|
||||
/// event said nothing at all. Debug builds only.
|
||||
static var testObserver: ((ConversationKey, String, Bool) -> Void)?
|
||||
/// Every message notice actually handed to the system, after every
|
||||
/// setting, mute and snooze has had its say. Debug builds only.
|
||||
static var testPostedObserver: ((ConversationKey, String, Bool) -> Void)?
|
||||
/// Every incoming-call banner the settings, mute and a snooze let
|
||||
/// through, by caller, before the check that the app is not in front,
|
||||
/// which a test host always is. Debug builds only.
|
||||
static var testCallBannerObserver: ((Int) -> Void)?
|
||||
#endif
|
||||
|
||||
/// Surface an incoming message.
|
||||
func postReplyReminder(fromBuddyID buddyID: Int, name: String?) {
|
||||
let d = UserDefaults.standard
|
||||
@@ -115,13 +129,22 @@ final class NotificationManager {
|
||||
}
|
||||
|
||||
/// Announce a call as a CALL.
|
||||
func postIncomingCall(fromBuddyID buddyID: Int, fromName: String, video: Bool) {
|
||||
///
|
||||
/// - Parameter snoozed: the caller's chat is snoozed right now, which
|
||||
/// holds this banner exactly as mute does, until the snooze ends.
|
||||
/// CallKit's own ringing is not decided here.
|
||||
func postIncomingCall(
|
||||
fromBuddyID buddyID: Int, fromName: String, video: Bool, snoozed: Bool
|
||||
) {
|
||||
let d = UserDefaults.standard
|
||||
let pushOn = d.object(forKey: SettingsKeys.pushEnabled) as? Bool ?? true
|
||||
let msgOn = d.object(forKey: SettingsKeys.notifyNewMessage) as? Bool ?? true
|
||||
let preview = d.object(forKey: SettingsKeys.showPreviews) as? Bool ?? false
|
||||
let muted = Self.muteKey(for: buddyID).map { d.bool(forKey: $0) } ?? false
|
||||
let muted = (Self.muteKey(for: buddyID).map { d.bool(forKey: $0) } ?? false) || snoozed
|
||||
guard pushOn, msgOn, !muted else { return }
|
||||
#if DEBUG
|
||||
Self.testCallBannerObserver?(buddyID)
|
||||
#endif
|
||||
// Not while the app is in front.
|
||||
guard UIApplication.shared.applicationState != .active else { return }
|
||||
|
||||
@@ -146,11 +169,22 @@ final class NotificationManager {
|
||||
|
||||
/// - Parameter thread: the conversation this arrived in, a buddy, a
|
||||
/// group, or a federated address.
|
||||
/// - Parameter snoozed: the conversation is snoozed right now. A snooze
|
||||
/// holds its notices exactly as mute does, until it ends. Only a
|
||||
/// one-to-one chat can be snoozed, and only the inbox knows until when,
|
||||
/// so every caller says, rather than leaning on a default that forgets.
|
||||
/// - Parameter quiet: shown without a sound, for news that can wait, such
|
||||
/// as the other person's move in a game (§6.33).
|
||||
func postIncomingMessage(
|
||||
in thread: ConversationKey, fromBuddyID buddyID: Int,
|
||||
fromName: String, body: String,
|
||||
mentionsMe: Bool = false
|
||||
snoozed: Bool,
|
||||
mentionsMe: Bool = false,
|
||||
quiet: Bool = false
|
||||
) {
|
||||
#if DEBUG
|
||||
Self.testObserver?(thread, body, quiet)
|
||||
#endif
|
||||
let d = UserDefaults.standard
|
||||
let pushOn = d.object(forKey: SettingsKeys.pushEnabled) as? Bool ?? true
|
||||
let msgOn = d.object(forKey: SettingsKeys.notifyNewMessage) as? Bool ?? true
|
||||
@@ -160,7 +194,8 @@ final class NotificationManager {
|
||||
// Keyed on the CONVERSATION, not on who is talking in it, and hashed
|
||||
// so the key does not name it. A Keychain that cannot answer reads as
|
||||
// NOT muted: the alternative is a call that silently never rings.
|
||||
let muted = Self.muteKey(for: thread).map { d.bool(forKey: $0) } ?? false
|
||||
// A snooze is a mute that ends: "this chat just stops asking".
|
||||
let muted = (Self.muteKey(for: thread).map { d.bool(forKey: $0) } ?? false) || snoozed
|
||||
|
||||
// Not for the conversation you are already reading.
|
||||
let reading =
|
||||
@@ -179,13 +214,13 @@ final class NotificationManager {
|
||||
mentionsMe: mentionsMe,
|
||||
mentionsBreakMute: mentionsBreakMute)
|
||||
else { return }
|
||||
if sounds { AudioServicesPlaySystemSound(1007) }
|
||||
if sounds, !quiet { AudioServicesPlaySystemSound(1007) }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
// The name is part of the preview.
|
||||
content.title = preview ? fromName : "Flamenet"
|
||||
content.body = preview ? body : String(localized: "New message")
|
||||
content.sound = .default
|
||||
content.sound = quiet ? nil : .default
|
||||
content.userInfo = ["buddyID": buddyID]
|
||||
|
||||
// Says WHICH buddy and how long the body was, never the body.
|
||||
@@ -201,6 +236,9 @@ final class NotificationManager {
|
||||
content: content,
|
||||
trigger: nil
|
||||
)
|
||||
#if DEBUG
|
||||
Self.testPostedObserver?(thread, body, quiet)
|
||||
#endif
|
||||
center.add(req, withCompletionHandler: nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,12 @@ enum ParentalControls {
|
||||
isEnabled && UserDefaults.standard.bool(forKey: callsDisabledKey)
|
||||
}
|
||||
|
||||
/// The Today tab, and the small-web directory reached from it, are not
|
||||
/// there. Not a gate a parent releases: they go whenever supervision is on
|
||||
/// (the owner's decision, DESIGN decision 31), because both lead straight
|
||||
/// out to the open web.
|
||||
static var todayHidden: Bool { isEnabled }
|
||||
|
||||
// MARK: - Setting and checking
|
||||
|
||||
static func enable(pin: String) {
|
||||
|
||||
@@ -94,6 +94,50 @@ final class AuthService {
|
||||
return account
|
||||
}
|
||||
|
||||
/// How a restore went. A relay that cannot be reached is not one that
|
||||
/// refused the credential, and only the second means signing in again.
|
||||
enum RestoreOutcome: Equatable {
|
||||
case restored
|
||||
/// The relay answered and turned the credential away.
|
||||
case refused
|
||||
/// No answer that says anything about the credential: no network, the
|
||||
/// relay down, or a failure on its side.
|
||||
case unreachable
|
||||
}
|
||||
|
||||
/// Restore a stored session, and say which way it went. On `unreachable`
|
||||
/// the credential stays presented, so whatever runs next uses it.
|
||||
func restoreOutcome(username: String, appPassword: String) async -> RestoreOutcome {
|
||||
client.setBasicAuth(username: username, appPassword: appPassword)
|
||||
do {
|
||||
currentUser = try await fetchMe()
|
||||
return .restored
|
||||
} catch {
|
||||
guard Self.isRefusal(error) else { return .unreachable }
|
||||
client.clearBasicAuth()
|
||||
return .refused
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an error is the relay refusing the credential, as opposed to
|
||||
/// the relay not being reached.
|
||||
static func isRefusal(_ error: Error) -> Bool {
|
||||
switch error as? MessengerError {
|
||||
case .ajaxFailure("bad_credentials")?, .unauthorized?, .refused?, .notAuthenticated?:
|
||||
return true
|
||||
case .httpStatus(let code)?:
|
||||
return code == 401 || code == 403
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Present the stored credential on every request from here on, without
|
||||
/// making one: what a launch with no network does before it can ask.
|
||||
func presentStoredCredential(username: String, appPassword: String) {
|
||||
client.setBasicAuth(username: username, appPassword: appPassword)
|
||||
}
|
||||
|
||||
/// Restore a previously-cached session without prompting the user.
|
||||
@discardableResult
|
||||
func restore(username: String, appPassword: String) async -> Bool {
|
||||
|
||||
@@ -80,6 +80,60 @@ struct SiteConfig: Codable, Equatable {
|
||||
messengerPagePath: "messenger/"
|
||||
)
|
||||
|
||||
/// Where a DEBUG test launch talks instead: a closed port on this
|
||||
/// machine, so nothing it does reaches a relay at all.
|
||||
static let testLoopback = SiteConfig(baseURL: URL(string: "http://127.0.0.1:9")!)
|
||||
|
||||
/// Hosts that are the production service. A test launch never talks to
|
||||
/// one unless that launch named it itself.
|
||||
static func isProduction(_ url: URL) -> Bool {
|
||||
guard let host = url.host?.lowercased() else { return false }
|
||||
return host == "flamenet.io" || host.hasSuffix(".flamenet.io")
|
||||
|| host == "flamenetmessenger.com" || host.hasSuffix(".flamenetmessenger.com")
|
||||
}
|
||||
|
||||
/// Whether this process is a test: the unit-test host, or the app launched
|
||||
/// by a UI suite, which says so in `FNMSG_TEST_LAUNCH`. Release builds are
|
||||
/// never one.
|
||||
static func isTestLaunch(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||
) -> Bool {
|
||||
#if DEBUG
|
||||
return environment["XCTestConfigurationFilePath"] != nil
|
||||
|| environment["FNMSG_TEST_LAUNCH"] == "1"
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The site this launch uses: the stored one, or the shipped default.
|
||||
///
|
||||
/// A test launch (DEBUG only) never ends up on production by default. Its
|
||||
/// default is `testLoopback`, and a stored production site, left behind by
|
||||
/// an earlier suite on the same simulator, is replaced by it too, unless
|
||||
/// this launch named that site itself with `-FNMSGServer`, which is how an
|
||||
/// opt-in live suite says it means it. The unit-test host has no way to
|
||||
/// name one. The shipped app is untouched: `isTestLaunch` is false there.
|
||||
static func forThisLaunch(
|
||||
stored: SiteConfig?,
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
arguments: [String] = ProcessInfo.processInfo.arguments
|
||||
) -> SiteConfig {
|
||||
let chosen = stored ?? .default
|
||||
guard isTestLaunch(environment: environment), isProduction(chosen.baseURL) else {
|
||||
return chosen
|
||||
}
|
||||
let unitHost = environment["XCTestConfigurationFilePath"] != nil
|
||||
if !unitHost, let index = arguments.firstIndex(of: "-FNMSGServer"),
|
||||
index + 1 < arguments.count,
|
||||
let named = makeBaseURL(from: arguments[index + 1]),
|
||||
named.host?.lowercased() == chosen.baseURL.host?.lowercased()
|
||||
{
|
||||
return chosen
|
||||
}
|
||||
return .testLoopback
|
||||
}
|
||||
|
||||
/// Where to send somebody who wants to pay for the project rather than
|
||||
/// for the relay they happen to be signed in to.
|
||||
///
|
||||
@@ -161,6 +215,7 @@ struct SessionStore {
|
||||
private let configKey = "fnmsg.siteConfig"
|
||||
private let usernameKey = "fnmsg.username"
|
||||
private let appPasswordKey = "fnmsg.appPassword"
|
||||
private let userIDKey = "fnmsg.userID"
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
@@ -185,6 +240,20 @@ struct SessionStore {
|
||||
nonmutating set { defaults.set(newValue, forKey: usernameKey) }
|
||||
}
|
||||
|
||||
/// The account's user id, kept so a launch with no network can open this
|
||||
/// account's own chats from the phone (DESIGN decision 47). Not a secret:
|
||||
/// it names the account to the relay, and the relay already knows it.
|
||||
var userID: Int? {
|
||||
get { (defaults.object(forKey: userIDKey) as? NSNumber)?.intValue }
|
||||
nonmutating set {
|
||||
if let newValue {
|
||||
defaults.set(newValue, forKey: userIDKey)
|
||||
} else {
|
||||
defaults.removeObject(forKey: userIDKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// the relay Application Password, the account credential, so it lives in
|
||||
/// the Keychain (device-only, no iCloud sync).
|
||||
var appPassword: String? {
|
||||
@@ -212,6 +281,7 @@ struct SessionStore {
|
||||
defaults.removeObject(forKey: configKey)
|
||||
defaults.removeObject(forKey: usernameKey)
|
||||
defaults.removeObject(forKey: appPasswordKey)
|
||||
defaults.removeObject(forKey: userIDKey)
|
||||
CredentialKeychain.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,7 +328,8 @@ final class CallManager: NSObject, ObservableObject {
|
||||
// on a real phone.
|
||||
if !VoIPCallReceiver.shared.isShowingACall {
|
||||
NotificationManager.shared.postIncomingCall(
|
||||
fromBuddyID: userID, fromName: peerName, video: signal.video)
|
||||
fromBuddyID: userID, fromName: peerName, video: signal.video,
|
||||
snoozed: inbox.isSnoozed(userID))
|
||||
}
|
||||
startFastPolling()
|
||||
// Somebody already said no on the lock screen, before the offer
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import Foundation
|
||||
|
||||
/// The small-web directory, shipped inside the app as
|
||||
/// `directory/directory-v1.json`: FlameNet's public, approved listings,
|
||||
/// exported once and read from this phone, so browsing and searching it asks
|
||||
/// nothing of any server.
|
||||
///
|
||||
/// ```
|
||||
/// {"v":1,"made":"2026-10-04","from":"https://flamenet.io/directory/","note":"…",
|
||||
/// "cats":[{"id":"arts","name":"Arts","parent":""}],
|
||||
/// "sites":[{"t":"Example","u":"https://example.com/","d":"One line.","c":["arts"]}]}
|
||||
/// ```
|
||||
struct SmallWebDirectory {
|
||||
|
||||
struct Category: Identifiable, Hashable {
|
||||
let id: String
|
||||
let name: String
|
||||
/// Empty for a top-level category.
|
||||
let parent: String
|
||||
}
|
||||
|
||||
struct Site: Identifiable, Hashable {
|
||||
/// Its position in the file.
|
||||
let id: Int
|
||||
let title: String
|
||||
/// The link exactly as the file has it, which is what gets sent.
|
||||
let link: String
|
||||
let url: URL
|
||||
/// Drafted with software and checked by hand; the file's `note` says
|
||||
/// so, and it is never sent as the site's own words.
|
||||
let description: String?
|
||||
let categories: [String]
|
||||
|
||||
var host: String { TodayFormat.host(url) }
|
||||
|
||||
/// Title, host and description, folded for search.
|
||||
fileprivate let searchText: String
|
||||
fileprivate let foldedTitle: String
|
||||
}
|
||||
|
||||
let made: String
|
||||
let source: String
|
||||
/// Whose words the descriptions are, shown once at the top of the
|
||||
/// directory.
|
||||
let note: String?
|
||||
let categories: [Category]
|
||||
let sites: [Site]
|
||||
|
||||
private let sitesByCategory: [String: [Site]]
|
||||
private let childrenByParent: [String: [Category]]
|
||||
private let counts: [String: Int]
|
||||
|
||||
enum Bound {
|
||||
static let title = 120
|
||||
static let description = 300
|
||||
static let name = 80
|
||||
static let note = 400
|
||||
}
|
||||
|
||||
// MARK: - Loading
|
||||
|
||||
/// The copy shipped with the app, read once, on first use.
|
||||
static let bundled: SmallWebDirectory? = {
|
||||
let url =
|
||||
Bundle.main.url(forResource: "directory-v1", withExtension: "json", subdirectory: "directory")
|
||||
?? Bundle.main.url(forResource: "directory-v1", withExtension: "json")
|
||||
guard let url, let data = try? Data(contentsOf: url) else { return nil }
|
||||
return decode(data)
|
||||
}()
|
||||
|
||||
static func decode(_ data: Data) -> SmallWebDirectory? {
|
||||
guard let object = try? JSONSerialization.jsonObject(with: data),
|
||||
let root = object as? [String: Any],
|
||||
TodayJSON.integer(root["v"]) == 1
|
||||
else { return nil }
|
||||
|
||||
let categories = TodayJSON.objects(root["cats"]).compactMap { item -> Category? in
|
||||
guard let id = TodayJSON.text(item["id"], max: Bound.name), !id.isEmpty,
|
||||
let name = TodayJSON.text(item["name"], max: Bound.name), !name.isEmpty
|
||||
else { return nil }
|
||||
return Category(
|
||||
id: id, name: name, parent: TodayJSON.text(item["parent"], max: Bound.name) ?? "")
|
||||
}
|
||||
var sites: [Site] = []
|
||||
for (index, item) in TodayJSON.objects(root["sites"]).enumerated() {
|
||||
guard let title = TodayJSON.text(item["t"], max: Bound.title), !title.isEmpty,
|
||||
let link = (item["u"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
let url = TodayJSON.link(link)
|
||||
else { continue }
|
||||
let description = TodayJSON.text(item["d"], max: Bound.description).flatMap {
|
||||
$0.isEmpty ? nil : $0
|
||||
}
|
||||
let cats = TodayJSON.list(item["c"]).compactMap { $0 as? String }
|
||||
let foldedTitle = fold(title)
|
||||
sites.append(
|
||||
Site(
|
||||
id: index, title: title, link: link, url: url,
|
||||
description: description, categories: cats,
|
||||
searchText: [foldedTitle, fold(TodayFormat.host(url)), fold(description ?? "")]
|
||||
.joined(separator: "\n"),
|
||||
foldedTitle: foldedTitle))
|
||||
}
|
||||
return SmallWebDirectory(
|
||||
made: TodayJSON.text(root["made"], max: 10) ?? "",
|
||||
source: TodayJSON.text(root["from"], max: 200) ?? "",
|
||||
note: TodayJSON.text(root["note"], max: Bound.note).flatMap { $0.isEmpty ? nil : $0 },
|
||||
categories: categories, sites: sites)
|
||||
}
|
||||
|
||||
private init(
|
||||
made: String, source: String, note: String?, categories: [Category], sites: [Site]
|
||||
) {
|
||||
self.made = made
|
||||
self.source = source
|
||||
self.note = note
|
||||
self.categories = categories
|
||||
self.sites = sites
|
||||
|
||||
var byCategory: [String: [Site]] = [:]
|
||||
for site in sites {
|
||||
for id in Set(site.categories) { byCategory[id, default: []].append(site) }
|
||||
}
|
||||
sitesByCategory = byCategory.mapValues {
|
||||
$0.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
|
||||
}
|
||||
childrenByParent = Dictionary(grouping: categories.filter { !$0.parent.isEmpty }, by: \.parent)
|
||||
|
||||
// A category counts the sites in it and in every category under it,
|
||||
// each site once. The walk keeps its own trail, so a file that names
|
||||
// a category as its own ancestor ends the walk rather than looping.
|
||||
let children = childrenByParent
|
||||
func under(_ id: String, trail: Set<String>) -> Set<Int> {
|
||||
var found = Set((byCategory[id] ?? []).map(\.id))
|
||||
for child in children[id] ?? [] where !trail.contains(child.id) {
|
||||
found.formUnion(under(child.id, trail: trail.union([child.id])))
|
||||
}
|
||||
return found
|
||||
}
|
||||
var counts: [String: Int] = [:]
|
||||
for category in categories {
|
||||
counts[category.id] = under(category.id, trail: [category.id]).count
|
||||
}
|
||||
self.counts = counts
|
||||
}
|
||||
|
||||
// MARK: - Browsing
|
||||
|
||||
/// The categories with no parent, in the file's order.
|
||||
var topLevel: [Category] { categories.filter { $0.parent.isEmpty } }
|
||||
|
||||
func children(of id: String) -> [Category] { childrenByParent[id] ?? [] }
|
||||
|
||||
/// The sites filed directly under `id`, by title.
|
||||
func sites(in id: String) -> [Site] { sitesByCategory[id] ?? [] }
|
||||
|
||||
/// Sites under `id` or any category below it.
|
||||
func count(in id: String) -> Int { counts[id] ?? 0 }
|
||||
|
||||
// MARK: - Search
|
||||
|
||||
/// Every site whose title, host or description holds all the words typed,
|
||||
/// titles that start with the first word first. On this phone only.
|
||||
func search(_ query: String) -> [Site] {
|
||||
let words = Self.fold(query).split(whereSeparator: \.isWhitespace).map(String.init)
|
||||
guard let first = words.first else { return [] }
|
||||
let found = sites.filter { site in words.allSatisfy { site.searchText.contains($0) } }
|
||||
func rank(_ site: Site) -> Int {
|
||||
if site.foldedTitle.hasPrefix(first) { return 0 }
|
||||
if words.allSatisfy({ site.foldedTitle.contains($0) }) { return 1 }
|
||||
return 2
|
||||
}
|
||||
return found.sorted { a, b in
|
||||
let (ra, rb) = (rank(a), rank(b))
|
||||
if ra != rb { return ra < rb }
|
||||
return a.title.localizedCaseInsensitiveCompare(b.title) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
/// Case, accents and full-width forms set aside, so "cafe" finds "Café".
|
||||
static func fold(_ text: String) -> String {
|
||||
text.folding(options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], locale: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// "Send to a chat" for a directory entry: an ordinary text holding the link,
|
||||
/// with a §6.12 preview made from the directory itself, so nobody fetches the
|
||||
/// site to draw it, not the person sending and not the person receiving.
|
||||
enum DirectoryShare {
|
||||
|
||||
/// The text and the preview a share sends.
|
||||
///
|
||||
/// The preview carries the title and nothing else. The description was
|
||||
/// drafted with software, and a preview reads as the site's own words
|
||||
/// (DESIGN decision 6). `desc` is present and empty rather than absent:
|
||||
/// iOS builds up to 0.31 cannot read a preview without one.
|
||||
///
|
||||
/// With link previews turned off, the link goes alone.
|
||||
static func message(for site: SmallWebDirectory.Site, previewsOn: Bool)
|
||||
-> (text: String, preview: LinkPreview?)
|
||||
{
|
||||
let preview = previewsOn ? LinkPreview(url: site.link, title: site.title, desc: "") : nil
|
||||
return (site.link, preview)
|
||||
}
|
||||
|
||||
/// Whether this person has link previews on, the setting under Data and
|
||||
/// storage. On unless they turned it off.
|
||||
static var previewsOn: Bool {
|
||||
UserDefaults.standard.object(forKey: SettingsKeys.linkPreviews) as? Bool ?? true
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
/// Section 8.11, stage D2: reads about OTHER people that name nobody.
|
||||
/// Section 8.13, stage D2: reads about OTHER people that name nobody.
|
||||
///
|
||||
/// Every read here is a POST with its target in the body, never in the URL,
|
||||
/// so the target is in no proxy's access log; and it goes out on its own
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import Foundation
|
||||
|
||||
/// A greeting card that travels inside a message, as a `card` content
|
||||
/// envelope (§6.31).
|
||||
///
|
||||
/// `open` asks the receiver's app to keep the card closed until that moment.
|
||||
/// The words are already on the receiver's phone when it arrives, so this is a
|
||||
/// courtesy between apps that keep it, the same standing as a story's expiry,
|
||||
/// and nothing on screen may call it more than that.
|
||||
struct ChatCard: Hashable, Codable {
|
||||
/// The design id as sent. Kept even when this build does not know it, so
|
||||
/// a backup or a forward carries what the sender chose.
|
||||
var d: String
|
||||
/// The salutation line.
|
||||
var to: String?
|
||||
/// The words inside. May be empty.
|
||||
var msg: String
|
||||
/// The signature line.
|
||||
var from: String?
|
||||
/// Unix seconds. Until then the receiver shows the card sealed.
|
||||
var open: Int?
|
||||
|
||||
init(d: String, to: String? = nil, msg: String, from: String? = nil, open: Int? = nil) {
|
||||
self.d = d
|
||||
self.to = to
|
||||
self.msg = msg
|
||||
self.from = from
|
||||
self.open = open
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case d, to, msg, from, open }
|
||||
|
||||
/// Lenient: a field of the wrong type is read as absent, never as a reason
|
||||
/// to drop the whole envelope onto the plain-text path.
|
||||
init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
d = (try? c.decodeIfPresent(String.self, forKey: .d)) ?? CardDesign.hello.rawValue
|
||||
to = try? c.decodeIfPresent(String.self, forKey: .to)
|
||||
msg = (try? c.decodeIfPresent(String.self, forKey: .msg)) ?? ""
|
||||
from = try? c.decodeIfPresent(String.self, forKey: .from)
|
||||
open = WireNumber.integer(c, .open)
|
||||
}
|
||||
|
||||
func encode(to encoder: any Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
try c.encode(d, forKey: .d)
|
||||
try c.encodeIfPresent(to, forKey: .to)
|
||||
try c.encode(msg, forKey: .msg)
|
||||
try c.encodeIfPresent(from, forKey: .from)
|
||||
try c.encodeIfPresent(open, forKey: .open)
|
||||
}
|
||||
|
||||
// MARK: - Render bounds
|
||||
|
||||
static let toBound = 60
|
||||
static let fromBound = 60
|
||||
static let msgBound = 2000
|
||||
|
||||
/// The design to draw. An id this build does not know draws as `hello`.
|
||||
var design: CardDesign { CardDesign(wire: d) }
|
||||
|
||||
/// The lines as they are drawn: bounded, and absent when blank.
|
||||
var shownTo: String? { Self.line(to, bound: Self.toBound) }
|
||||
var shownFrom: String? { Self.line(from, bound: Self.fromBound) }
|
||||
var shownMsg: String { String(msg.prefix(Self.msgBound)) }
|
||||
|
||||
private static func line(_ s: String?, bound: Int) -> String? {
|
||||
guard let s, !s.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
|
||||
return String(s.prefix(bound))
|
||||
}
|
||||
|
||||
/// The words of an open card as plain text, for copying and reporting.
|
||||
var plainText: String {
|
||||
[shownTo, shownMsg.isEmpty ? nil : shownMsg, shownFrom].compactMap { $0 }
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
|
||||
// MARK: - Sealed until
|
||||
|
||||
/// How far ahead a sender may seal a card, and how long a receiver keeps
|
||||
/// one closed at most, counted from when it arrived.
|
||||
static let maximumSealDays = 400
|
||||
|
||||
/// The latest `open` a sender may write, given the time now.
|
||||
static func latestOpen(now: Date) -> Date {
|
||||
now.addingTimeInterval(TimeInterval(maximumSealDays) * 86_400)
|
||||
}
|
||||
|
||||
/// The `open` a sender writes for a chosen moment: whole seconds, capped at
|
||||
/// `maximumSealDays`, and nil when the moment has already passed.
|
||||
static func openValue(for date: Date, now: Date) -> Int? {
|
||||
let capped = min(date, latestOpen(now: now))
|
||||
guard capped > now else { return nil }
|
||||
return Int(capped.timeIntervalSince1970.rounded(.down))
|
||||
}
|
||||
|
||||
/// When the receiver's copy opens, or nil if it was never sealed.
|
||||
///
|
||||
/// Bounded by `maximumSealDays` from arrival, so a sender's clock or a
|
||||
/// hostile value cannot keep a card shut for good.
|
||||
func opensAt(receivedAt: Date) -> Date? {
|
||||
guard let open else { return nil }
|
||||
let asked = Date(timeIntervalSince1970: TimeInterval(open))
|
||||
return min(asked, receivedAt.addingTimeInterval(TimeInterval(Self.maximumSealDays) * 86_400))
|
||||
}
|
||||
|
||||
/// Whether this copy is closed right now. The sender's own copy never is.
|
||||
func isSealed(isMine: Bool, receivedAt: Date, now: Date) -> Bool {
|
||||
guard !isMine, let opens = opensAt(receivedAt: receivedAt) else { return false }
|
||||
return now < opens
|
||||
}
|
||||
|
||||
// MARK: - Words about it
|
||||
|
||||
/// A date as the reader's calendar shows it: "January 1, 2026".
|
||||
static func day(
|
||||
_ date: Date, timeZone: TimeZone = .current, locale: Locale = .current
|
||||
) -> String {
|
||||
let f = DateFormatter()
|
||||
f.locale = locale
|
||||
f.timeZone = timeZone
|
||||
f.dateStyle = .long
|
||||
f.timeStyle = .none
|
||||
return f.string(from: date)
|
||||
}
|
||||
|
||||
/// The notification line, when previews are on. Never the words of the
|
||||
/// card, sealed or not: a notification is read on a lock screen.
|
||||
func notice(
|
||||
from name: String, receivedAt: Date, now: Date,
|
||||
timeZone: TimeZone = .current, locale: Locale = .current
|
||||
) -> String {
|
||||
if isSealed(isMine: false, receivedAt: receivedAt, now: now),
|
||||
let opens = opensAt(receivedAt: receivedAt)
|
||||
{
|
||||
return "\(name) sent you a card to open on "
|
||||
+ Self.day(opens, timeZone: timeZone, locale: locale)
|
||||
}
|
||||
return "\(name) sent you a card"
|
||||
}
|
||||
|
||||
/// What a conversation list or a reply quote says about this card. The
|
||||
/// title of the front, never the words inside.
|
||||
var summary: String { "Card: \(design.title)" }
|
||||
}
|
||||
|
||||
/// The nine flamenet.io card designs (`FNCD_Designs`).
|
||||
enum CardDesign: String, CaseIterable, Identifiable {
|
||||
case birthday, thanks, thinking, congrats, getwell, love, season, hello, sympathy
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
/// An id this build does not know draws as `hello`.
|
||||
init(wire: String) { self = CardDesign(rawValue: wire) ?? .hello }
|
||||
|
||||
/// The front title, drawn by the app as text, the same titles as the site.
|
||||
var title: String {
|
||||
switch self {
|
||||
case .birthday: return "Happy Birthday"
|
||||
case .thanks: return "Thank You"
|
||||
case .thinking: return "Thinking of You"
|
||||
case .congrats: return "Congratulations"
|
||||
case .getwell: return "Get Well Soon"
|
||||
case .love: return "With Love"
|
||||
case .season: return "Season's Greetings"
|
||||
case .hello: return "Hello!"
|
||||
case .sympathy: return "With Sympathy"
|
||||
}
|
||||
}
|
||||
|
||||
/// What the drawing shows, for VoiceOver.
|
||||
var drawing: String {
|
||||
switch self {
|
||||
case .birthday: return "three balloons and confetti"
|
||||
case .thanks: return "three flowers"
|
||||
case .thinking: return "a crescent moon and stars"
|
||||
case .congrats: return "a bright star burst"
|
||||
case .getwell: return "the sun and a cloud"
|
||||
case .love: return "a big heart and small hearts"
|
||||
case .season: return "a tree in the snow"
|
||||
case .hello: return "a paper plane"
|
||||
case .sympathy: return "a lit candle"
|
||||
}
|
||||
}
|
||||
|
||||
/// The front image, `cards/<id>.png` in the bundle.
|
||||
var imageName: String { rawValue }
|
||||
}
|
||||
|
||||
/// Numbers as JSON carries them from either app: an integer, a number with a
|
||||
/// fraction, or a boolean where a flag is meant.
|
||||
enum WireNumber {
|
||||
/// An integer field written as a whole number or as a number with a
|
||||
/// fraction (rounded down). Anything else reads as absent.
|
||||
static func integer<K: CodingKey>(_ c: KeyedDecodingContainer<K>, _ key: K) -> Int? {
|
||||
if let n = try? c.decodeIfPresent(Int.self, forKey: key) { return n }
|
||||
if let d = try? c.decodeIfPresent(Double.self, forKey: key), d.isFinite,
|
||||
abs(d) < 9e15
|
||||
{
|
||||
return Int(d.rounded(.down))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// A flag written as `1`, `0`, `true` or `false`. Absent or anything else
|
||||
/// reads as off.
|
||||
static func flag<K: CodingKey>(_ c: KeyedDecodingContainer<K>, _ key: K) -> Bool {
|
||||
if let n = try? c.decodeIfPresent(Int.self, forKey: key) { return n != 0 }
|
||||
if let b = try? c.decodeIfPresent(Bool.self, forKey: key) { return b }
|
||||
return false
|
||||
}
|
||||
|
||||
/// JSON-quote a string for a hand-built envelope. Slashes stay as they
|
||||
/// are, so `America/New_York` is written the way the design shows it.
|
||||
static func quoted(_ s: String) -> String {
|
||||
(try? String(data: quoter.encode(s), encoding: .utf8)) ?? "\"\""
|
||||
}
|
||||
|
||||
private static let quoter: JSONEncoder = {
|
||||
let e = JSONEncoder()
|
||||
e.outputFormatting = [.withoutEscapingSlashes]
|
||||
return e
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
import Foundation
|
||||
|
||||
/// An event invitation that travels inside a message, as an `event` content
|
||||
/// envelope (§6.32).
|
||||
///
|
||||
/// `id` is stable across revisions and a higher `rev` replaces the whole
|
||||
/// event. Only its organizer, the sender of rev 1 by ratchet identity, may
|
||||
/// revise or cancel it; see `EventState.fold`.
|
||||
struct ChatEvent: Hashable, Codable {
|
||||
var id: String
|
||||
var rev: Int
|
||||
var title: String
|
||||
/// Unix seconds, for an event at a time.
|
||||
var start: Int?
|
||||
/// Unix seconds, optional, never before `start`.
|
||||
var end: Int?
|
||||
/// `yyyy-MM-dd`, for an all-day event, in place of `start` and `end`.
|
||||
var day: String?
|
||||
/// How many days an all-day event runs, when more than one.
|
||||
var days: Int?
|
||||
/// The organizer's IANA zone, for "7:00 PM New York time".
|
||||
var tz: String?
|
||||
var place: String?
|
||||
var note: String?
|
||||
var cancel: Bool
|
||||
|
||||
init(
|
||||
id: String, rev: Int, title: String,
|
||||
start: Int? = nil, end: Int? = nil,
|
||||
day: String? = nil, days: Int? = nil,
|
||||
tz: String? = nil, place: String? = nil, note: String? = nil,
|
||||
cancel: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.rev = rev
|
||||
self.title = title
|
||||
self.start = start
|
||||
self.end = end
|
||||
self.day = day
|
||||
self.days = days
|
||||
self.tz = tz
|
||||
self.place = place
|
||||
self.note = note
|
||||
self.cancel = cancel
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, rev, title, start, end, day, days, tz, place, note, cancel
|
||||
}
|
||||
|
||||
/// Lenient, like `ChatCard`: a field of the wrong type reads as absent,
|
||||
/// and `isValid` decides whether what is left is an event at all.
|
||||
init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = (try? c.decodeIfPresent(String.self, forKey: .id)) ?? ""
|
||||
rev = WireNumber.integer(c, .rev) ?? 0
|
||||
title = (try? c.decodeIfPresent(String.self, forKey: .title)) ?? ""
|
||||
start = WireNumber.integer(c, .start)
|
||||
end = WireNumber.integer(c, .end)
|
||||
day = try? c.decodeIfPresent(String.self, forKey: .day)
|
||||
days = WireNumber.integer(c, .days)
|
||||
tz = try? c.decodeIfPresent(String.self, forKey: .tz)
|
||||
place = try? c.decodeIfPresent(String.self, forKey: .place)
|
||||
note = try? c.decodeIfPresent(String.self, forKey: .note)
|
||||
cancel = WireNumber.flag(c, .cancel)
|
||||
}
|
||||
|
||||
func encode(to encoder: any Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
try c.encode(id, forKey: .id)
|
||||
try c.encode(rev, forKey: .rev)
|
||||
try c.encode(title, forKey: .title)
|
||||
try c.encodeIfPresent(start, forKey: .start)
|
||||
try c.encodeIfPresent(end, forKey: .end)
|
||||
try c.encodeIfPresent(day, forKey: .day)
|
||||
try c.encodeIfPresent(days, forKey: .days)
|
||||
try c.encodeIfPresent(tz, forKey: .tz)
|
||||
try c.encodeIfPresent(place, forKey: .place)
|
||||
try c.encodeIfPresent(note, forKey: .note)
|
||||
if cancel { try c.encode(1, forKey: .cancel) }
|
||||
}
|
||||
|
||||
// MARK: - Bounds and shape
|
||||
|
||||
static let titleBound = 120
|
||||
static let placeBound = 200
|
||||
static let noteBound = 1000
|
||||
|
||||
/// Something a card can be drawn from: an id, a revision, a title, and a
|
||||
/// time or a day.
|
||||
var isValid: Bool {
|
||||
!id.isEmpty
|
||||
&& rev >= 1
|
||||
&& !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& (start != nil || dayDate != nil)
|
||||
}
|
||||
|
||||
/// A timed event wins over a day when a sender wrote both.
|
||||
var isAllDay: Bool { start == nil && dayDate != nil }
|
||||
|
||||
var shownTitle: String { String(title.prefix(Self.titleBound)) }
|
||||
var shownPlace: String? { Self.field(place, bound: Self.placeBound) }
|
||||
var shownNote: String? { Self.field(note, bound: Self.noteBound) }
|
||||
|
||||
private static func field(_ s: String?, bound: Int) -> String? {
|
||||
guard let s, !s.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
|
||||
return String(s.prefix(bound))
|
||||
}
|
||||
|
||||
var startDate: Date? { start.map { Date(timeIntervalSince1970: TimeInterval($0)) } }
|
||||
|
||||
/// The end, when there is a usable one: never before the start.
|
||||
var endDate: Date? {
|
||||
guard let start, let end, end >= start else { return nil }
|
||||
return Date(timeIntervalSince1970: TimeInterval(end))
|
||||
}
|
||||
|
||||
/// An all-day event's first day as calendar components, read the same way
|
||||
/// on every phone whatever its zone.
|
||||
var dayDate: DateComponents? {
|
||||
guard let day else { return nil }
|
||||
let parts = day.split(separator: "-")
|
||||
guard parts.count == 3, parts[0].count == 4, parts[1].count == 2, parts[2].count == 2,
|
||||
let y = Int(parts[0]), let m = Int(parts[1]), let d = Int(parts[2]),
|
||||
(1...12).contains(m), (1...31).contains(d)
|
||||
else { return nil }
|
||||
var c = DateComponents()
|
||||
c.year = y
|
||||
c.month = m
|
||||
c.day = d
|
||||
guard Self.gregorian.date(from: c).map({ Self.gregorian.component(.day, from: $0) == d })
|
||||
== true
|
||||
else { return nil }
|
||||
return c
|
||||
}
|
||||
|
||||
/// The longest an all-day event runs (DESIGN decision 45).
|
||||
static let maximumDays = 31
|
||||
|
||||
/// How many days an all-day event runs: one unless it says more, and
|
||||
/// never more than 31, whatever it says.
|
||||
var dayCount: Int { max(1, min(days ?? 1, Self.maximumDays)) }
|
||||
|
||||
static let gregorian: Calendar = {
|
||||
var c = Calendar(identifier: .gregorian)
|
||||
c.timeZone = TimeZone(identifier: "UTC")!
|
||||
return c
|
||||
}()
|
||||
|
||||
/// `yyyy-MM-dd` for a calendar day, the shape `day` travels in.
|
||||
static func dayString(year: Int, month: Int, day: Int) -> String {
|
||||
String(format: "%04d-%02d-%02d", year, month, day)
|
||||
}
|
||||
|
||||
// MARK: - Words about it
|
||||
|
||||
/// When it is, in the reader's zone: "Saturday, January 3, 2026, 7:00 PM
|
||||
/// to 9:00 PM", or the day for an all-day event.
|
||||
func when(timeZone: TimeZone = .current, locale: Locale = .current) -> String {
|
||||
if isAllDay, let first = dayDate {
|
||||
var cal = Calendar(identifier: .gregorian)
|
||||
cal.timeZone = timeZone
|
||||
guard let from = cal.date(from: first) else { return day ?? "" }
|
||||
let f = DateFormatter()
|
||||
f.locale = locale
|
||||
f.timeZone = timeZone
|
||||
f.dateStyle = .full
|
||||
f.timeStyle = .none
|
||||
if dayCount > 1,
|
||||
let last = cal.date(byAdding: .day, value: dayCount - 1, to: from)
|
||||
{
|
||||
return "\(f.string(from: from)) to \(f.string(from: last)), all day"
|
||||
}
|
||||
return "\(f.string(from: from)), all day"
|
||||
}
|
||||
guard let begin = startDate else { return "" }
|
||||
let day = DateFormatter()
|
||||
day.locale = locale
|
||||
day.timeZone = timeZone
|
||||
day.dateStyle = .full
|
||||
day.timeStyle = .short
|
||||
let time = DateFormatter()
|
||||
time.locale = locale
|
||||
time.timeZone = timeZone
|
||||
time.dateStyle = .none
|
||||
time.timeStyle = .short
|
||||
guard let finish = endDate, finish > begin else { return day.string(from: begin) }
|
||||
var cal = Calendar(identifier: .gregorian)
|
||||
cal.timeZone = timeZone
|
||||
if cal.isDate(begin, inSameDayAs: finish) {
|
||||
return "\(day.string(from: begin)) to \(time.string(from: finish))"
|
||||
}
|
||||
return "\(day.string(from: begin)) to \(day.string(from: finish))"
|
||||
}
|
||||
|
||||
/// "7:00 PM New York time" when the organizer zone's offset at the start
|
||||
/// differs from the reader's, with the weekday when the organizer's date
|
||||
/// differs too ("Fri 7:00 PM New York time"). Nil when it would say the
|
||||
/// same, and always nil for an all-day event.
|
||||
func organizerTime(readerZone: TimeZone = .current, locale: Locale = .current) -> String? {
|
||||
guard !isAllDay, let begin = startDate, let tz, let zone = TimeZone(identifier: tz)
|
||||
else { return nil }
|
||||
guard zone.secondsFromGMT(for: begin) != readerZone.secondsFromGMT(for: begin) else {
|
||||
return nil
|
||||
}
|
||||
var theirs = Calendar(identifier: .gregorian)
|
||||
theirs.timeZone = zone
|
||||
var ours = Calendar(identifier: .gregorian)
|
||||
ours.timeZone = readerZone
|
||||
let day: Set<Calendar.Component> = [.year, .month, .day]
|
||||
let f = DateFormatter()
|
||||
f.locale = locale
|
||||
f.timeZone = zone
|
||||
if theirs.dateComponents(day, from: begin) != ours.dateComponents(day, from: begin) {
|
||||
f.setLocalizedDateFormatFromTemplate("EEEjmm")
|
||||
} else {
|
||||
f.dateStyle = .none
|
||||
f.timeStyle = .short
|
||||
}
|
||||
let city = tz.split(separator: "/").last.map {
|
||||
$0.replacingOccurrences(of: "_", with: " ")
|
||||
} ?? tz
|
||||
return "\(f.string(from: begin)) \(city) time"
|
||||
}
|
||||
|
||||
/// The notification line for a new invitation, when previews are on.
|
||||
static func invitedNotice(from name: String, title: String) -> String {
|
||||
"\(name) invited you: \(String(title.prefix(titleBound)))"
|
||||
}
|
||||
|
||||
/// The notification line for a revision, with the title the reader had
|
||||
/// before it.
|
||||
static func changedNotice(title: String) -> String {
|
||||
"\(String(title.prefix(titleBound))) changed"
|
||||
}
|
||||
|
||||
/// The notification line for a cancellation.
|
||||
static func cancelledNotice(title: String) -> String {
|
||||
"\(String(title.prefix(titleBound))) was cancelled"
|
||||
}
|
||||
|
||||
/// What a conversation list or a reply quote says about this event.
|
||||
var summary: String { cancel ? "Cancelled: \(shownTitle)" : "Event: \(shownTitle)" }
|
||||
|
||||
/// The same event as a copy somebody passes on: a fresh id, its first
|
||||
/// revision, the forwarder as its organizer, nothing cancelled.
|
||||
func forwardedCopy(newID: String) -> ChatEvent {
|
||||
var copy = self
|
||||
copy.id = newID
|
||||
copy.rev = 1
|
||||
copy.cancel = false
|
||||
return copy
|
||||
}
|
||||
}
|
||||
|
||||
/// One person's answer to an event.
|
||||
enum RSVPAnswer: String, Codable, CaseIterable {
|
||||
case yes, maybe, no
|
||||
|
||||
/// The button and the count heading.
|
||||
var label: String {
|
||||
switch self {
|
||||
case .yes: return "Going"
|
||||
case .maybe: return "Maybe"
|
||||
case .no: return "Can't go"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An answer as it is kept: what, and its place in the sender's sequence.
|
||||
struct EventRSVP: Hashable, Codable {
|
||||
var r: RSVPAnswer
|
||||
/// The unix second it was given, or one past the last `n` that device
|
||||
/// sent for the event when that is later. The highest wins (§6.32).
|
||||
var n: Int
|
||||
|
||||
/// Whether this answer replaces `known` from the same person: a higher `n`,
|
||||
/// or the same `n` and an answer that sorts higher as a string (`yes`, then
|
||||
/// `no`, then `maybe`), so every phone settles on the same one whatever
|
||||
/// order they arrive in.
|
||||
func supersedes(_ known: EventRSVP?) -> Bool {
|
||||
guard let known else { return true }
|
||||
if n != known.n { return n > known.n }
|
||||
return r.rawValue > known.r.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
/// An answer as it arrives in an `rsvp` envelope, checked before anything
|
||||
/// folds it.
|
||||
struct RSVPPart: Equatable {
|
||||
let eventID: String
|
||||
let answer: RSVPAnswer
|
||||
let n: Int
|
||||
|
||||
/// The `rsvp` object off the wire. Nil unless all three fields are usable.
|
||||
struct Wire: Codable {
|
||||
var eid: String?
|
||||
var r: String?
|
||||
var n: Int?
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case eid, r, n }
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
eid = try? c.decodeIfPresent(String.self, forKey: .eid)
|
||||
r = try? c.decodeIfPresent(String.self, forKey: .r)
|
||||
n = WireNumber.integer(c, .n)
|
||||
}
|
||||
}
|
||||
|
||||
init(eventID: String, answer: RSVPAnswer, n: Int) {
|
||||
self.eventID = eventID
|
||||
self.answer = answer
|
||||
self.n = n
|
||||
}
|
||||
|
||||
init?(wire: Wire) {
|
||||
guard let eid = wire.eid, !eid.isEmpty,
|
||||
let raw = wire.r, let answer = RSVPAnswer(rawValue: raw),
|
||||
let n = wire.n, n >= 1
|
||||
else { return nil }
|
||||
self.init(eventID: eid, answer: answer, n: n)
|
||||
}
|
||||
}
|
||||
|
||||
/// What one conversation holds about one event: the latest accepted
|
||||
/// revision, who organised it, and the latest answer from each person.
|
||||
///
|
||||
/// Kept on the message the event first arrived on, so it goes wherever the
|
||||
/// message goes: the sealed history, the backup, an off-the-record thread
|
||||
/// held in memory, the disappearing timer, and delete.
|
||||
struct EventState: Hashable, Codable {
|
||||
var event: ChatEvent
|
||||
/// User id of the organizer, the only person whose revisions count.
|
||||
var organizer: Int
|
||||
/// False while the organizer is only the first sender seen because rev 1
|
||||
/// has not arrived. A rev 1 from somebody else then takes over.
|
||||
var organizerConfirmed: Bool
|
||||
/// The latest answer per person, keyed by user id.
|
||||
var rsvps: [Int: EventRSVP]
|
||||
|
||||
init(
|
||||
event: ChatEvent, organizer: Int, organizerConfirmed: Bool = true,
|
||||
rsvps: [Int: EventRSVP] = [:]
|
||||
) {
|
||||
self.event = event
|
||||
self.organizer = organizer
|
||||
self.organizerConfirmed = organizerConfirmed
|
||||
self.rsvps = rsvps
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case event, organizer, organizerConfirmed, rsvps
|
||||
}
|
||||
|
||||
/// Every field but the event itself has a default, so a history written by
|
||||
/// a later build that adds one still opens.
|
||||
init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
event = try c.decode(ChatEvent.self, forKey: .event)
|
||||
organizer = (try? c.decodeIfPresent(Int.self, forKey: .organizer)) ?? 0
|
||||
organizerConfirmed =
|
||||
(try? c.decodeIfPresent(Bool.self, forKey: .organizerConfirmed)) ?? true
|
||||
rsvps = (try? c.decodeIfPresent([Int: EventRSVP].self, forKey: .rsvps)) ?? [:]
|
||||
}
|
||||
|
||||
func encode(to encoder: any Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
try c.encode(event, forKey: .event)
|
||||
try c.encode(organizer, forKey: .organizer)
|
||||
try c.encode(organizerConfirmed, forKey: .organizerConfirmed)
|
||||
try c.encode(rsvps, forKey: .rsvps)
|
||||
}
|
||||
|
||||
/// What folding one event envelope did.
|
||||
enum Outcome: Equatable {
|
||||
/// The first this conversation has heard of it.
|
||||
case created
|
||||
/// A newer revision from the organizer replaced it.
|
||||
case revised
|
||||
/// That newer revision cancels it.
|
||||
case cancelled
|
||||
/// A rev 1 that settles who organises it, replacing a provisional
|
||||
/// organizer. Shown, never announced.
|
||||
case settled
|
||||
/// Nothing anybody can see changed: an older or equal revision, or one
|
||||
/// from somebody who is not the organizer.
|
||||
case unchanged
|
||||
}
|
||||
|
||||
/// Fold an incoming event into what this conversation already holds.
|
||||
///
|
||||
/// Highest `rev` wins; only the organizer may revise or cancel. The
|
||||
/// organizer is the sender of rev 1. When rev 1 was never seen, the first
|
||||
/// sender is held provisionally and a rev 1 from somebody else replaces
|
||||
/// them. Anybody else reusing a known id is ignored.
|
||||
static func fold(
|
||||
_ incoming: ChatEvent, from sender: Int, into existing: EventState?
|
||||
) -> (state: EventState?, outcome: Outcome) {
|
||||
guard incoming.isValid else { return (existing, .unchanged) }
|
||||
guard var state = existing else {
|
||||
return (
|
||||
EventState(
|
||||
event: incoming, organizer: sender,
|
||||
organizerConfirmed: incoming.rev == 1),
|
||||
.created
|
||||
)
|
||||
}
|
||||
if sender != state.organizer {
|
||||
guard !state.organizerConfirmed, incoming.rev == 1 else { return (state, .unchanged) }
|
||||
state.organizer = sender
|
||||
state.organizerConfirmed = true
|
||||
state.event = incoming
|
||||
return (state, .settled)
|
||||
}
|
||||
if incoming.rev == 1 { state.organizerConfirmed = true }
|
||||
guard incoming.rev > state.event.rev else { return (state, .unchanged) }
|
||||
state.event = incoming
|
||||
return (state, incoming.cancel ? .cancelled : .revised)
|
||||
}
|
||||
|
||||
/// Fold one person's answer: the highest `n` wins, and an equal `n` goes
|
||||
/// to the answer that sorts higher. Anything else is a duplicate or arrived
|
||||
/// out of order.
|
||||
@discardableResult
|
||||
mutating func fold(_ answer: EventRSVP, from sender: Int) -> Bool {
|
||||
guard answer.supersedes(rsvps[sender]) else { return false }
|
||||
rsvps[sender] = answer
|
||||
return true
|
||||
}
|
||||
|
||||
/// Who answered what, each list in user-id order.
|
||||
func answers(_ answer: RSVPAnswer) -> [Int] {
|
||||
rsvps.filter { $0.value.r == answer }.map(\.key).sorted()
|
||||
}
|
||||
|
||||
/// The `n` for this person's next answer: the unix second now, or one past
|
||||
/// the last `n` sent for this event when that is already at or beyond it.
|
||||
func nextN(for userID: Int, now: Date) -> Int {
|
||||
max(Int(now.timeIntervalSince1970.rounded(.down)), (rsvps[userID]?.n ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
extension E2EAttachment {
|
||||
|
||||
/// The `card` object as it travels, keys in the order both apps write.
|
||||
static func cardJSON(_ card: ChatCard) -> String {
|
||||
var parts = ["\"d\":\(WireNumber.quoted(card.d))"]
|
||||
if let to = card.to { parts.append("\"to\":\(WireNumber.quoted(to))") }
|
||||
parts.append("\"msg\":\(WireNumber.quoted(card.msg))")
|
||||
if let from = card.from { parts.append("\"from\":\(WireNumber.quoted(from))") }
|
||||
if let open = card.open { parts.append("\"open\":\(open)") }
|
||||
return "{" + parts.joined(separator: ",") + "}"
|
||||
}
|
||||
|
||||
/// The `ev` object as it travels, keys in the order both apps write. An
|
||||
/// all-day event carries no `tz`, `start` or `end`.
|
||||
static func eventJSON(_ ev: ChatEvent) -> String {
|
||||
var parts = [
|
||||
"\"id\":\(WireNumber.quoted(ev.id))", "\"rev\":\(ev.rev)",
|
||||
"\"title\":\(WireNumber.quoted(ev.title))",
|
||||
]
|
||||
if let day = ev.day, ev.start == nil {
|
||||
parts.append("\"day\":\(WireNumber.quoted(day))")
|
||||
if let days = ev.days, days > 1 { parts.append("\"days\":\(days)") }
|
||||
} else {
|
||||
if let start = ev.start { parts.append("\"start\":\(start)") }
|
||||
if let end = ev.end { parts.append("\"end\":\(end)") }
|
||||
if let tz = ev.tz { parts.append("\"tz\":\(WireNumber.quoted(tz))") }
|
||||
}
|
||||
if let place = ev.place { parts.append("\"place\":\(WireNumber.quoted(place))") }
|
||||
if let note = ev.note { parts.append("\"note\":\(WireNumber.quoted(note))") }
|
||||
if ev.cancel { parts.append("\"cancel\":1") }
|
||||
return "{" + parts.joined(separator: ",") + "}"
|
||||
}
|
||||
|
||||
/// A greeting card. Renderable, so it carries `mid`, and `gid`
|
||||
/// and `gname` in a group, exactly as a text does.
|
||||
static func encodeCard(
|
||||
_ card: ChatCard, mid: String, gid: String? = nil, groupName: String? = nil,
|
||||
forwarded: Bool = false
|
||||
) -> String {
|
||||
renderable(
|
||||
kind: "card", key: "card", body: cardJSON(card), mid: mid,
|
||||
gid: gid, groupName: groupName, forwarded: forwarded)
|
||||
}
|
||||
|
||||
/// An event, a revision, or a cancellation.
|
||||
static func encodeEvent(
|
||||
_ event: ChatEvent, mid: String, gid: String? = nil, groupName: String? = nil,
|
||||
forwarded: Bool = false
|
||||
) -> String {
|
||||
renderable(
|
||||
kind: "event", key: "ev", body: eventJSON(event), mid: mid,
|
||||
gid: gid, groupName: groupName, forwarded: forwarded)
|
||||
}
|
||||
|
||||
/// One person's answer to an event. Control traffic, so `ctl` is set and
|
||||
/// an older build drops it; `gid` routes it in a group, with no `gname`,
|
||||
/// the way a poll's control envelopes travel.
|
||||
static func encodeRSVP(_ part: RSVPPart, gid: String? = nil) -> String {
|
||||
let body =
|
||||
"{\"eid\":\(WireNumber.quoted(part.eventID)),"
|
||||
+ "\"r\":\"\(part.answer.rawValue)\",\"n\":\(part.n)}"
|
||||
var parts = ["\"fnc\":1", "\"kind\":\"rsvp\"", "\"ctl\":1", "\"rsvp\":\(body)"]
|
||||
if let gid { parts.append("\"gid\":\(WireNumber.quoted(gid))") }
|
||||
return "{" + parts.joined(separator: ",") + "}"
|
||||
}
|
||||
|
||||
/// `fnc, kind, [fwd], mid, <body>, [gid], [gname]`, the order both apps write.
|
||||
private static func renderable(
|
||||
kind: String, key: String, body: String, mid: String,
|
||||
gid: String?, groupName: String?, forwarded: Bool
|
||||
) -> String {
|
||||
var parts = ["\"fnc\":1", "\"kind\":\"\(kind)\""]
|
||||
if forwarded { parts.append("\"fwd\":1") }
|
||||
parts.append("\"mid\":\(WireNumber.quoted(mid))")
|
||||
parts.append("\"\(key)\":\(body)")
|
||||
if let gid { parts.append("\"gid\":\(WireNumber.quoted(gid))") }
|
||||
if let groupName, !groupName.isEmpty {
|
||||
parts.append("\"gname\":\(WireNumber.quoted(groupName))")
|
||||
}
|
||||
return "{" + parts.joined(separator: ",") + "}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
import FlamenetGames
|
||||
import Foundation
|
||||
|
||||
// MARK: - Wire (§6.33)
|
||||
|
||||
/// What a `gend` says (§6.33): an ending, or a draw offer and its answer.
|
||||
enum GameEndReason: String, CaseIterable, Hashable, Sendable {
|
||||
case resign
|
||||
case drawOffer = "draw-offer"
|
||||
case drawAccept = "draw-accept"
|
||||
case drawDecline = "draw-decline"
|
||||
/// The invitee says no before their first move.
|
||||
case decline
|
||||
/// The starter withdraws before the invitee's first move.
|
||||
case cancel
|
||||
|
||||
/// Whether this ends the game, as opposed to offering or refusing a draw.
|
||||
var ends: Bool { self != .drawOffer && self != .drawDecline }
|
||||
}
|
||||
|
||||
/// The `game` object of a `game`, `gmove` or `gend` envelope (§6.33).
|
||||
///
|
||||
/// Read leniently: an object that is not a game reads as absent, and the envelope then draws as
|
||||
/// unsupported (a start) or is dropped (a move or an end), never half read.
|
||||
struct GameWire: Hashable, Sendable {
|
||||
let id: String
|
||||
let type: GameType
|
||||
/// The side the SENDER of this envelope plays.
|
||||
let mine: Side
|
||||
/// Every ply so far, in order. Absent on a start.
|
||||
let log: [String]?
|
||||
/// `gend` only.
|
||||
let r: GameEndReason?
|
||||
}
|
||||
|
||||
extension GameWire: Codable {
|
||||
private enum Keys: String, CodingKey { case id, t, mine, log, r }
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: Keys.self)
|
||||
guard let id = try? c.decodeIfPresent(String.self, forKey: .id), !id.isEmpty,
|
||||
let type = (try? c.decodeIfPresent(String.self, forKey: .t))
|
||||
.flatMap(GameType.init(rawValue:)),
|
||||
let mine = (try? c.decodeIfPresent(String.self, forKey: .mine))
|
||||
.flatMap(Side.init(rawValue:))
|
||||
else {
|
||||
throw DecodingError.dataCorrupted(
|
||||
.init(codingPath: c.codingPath, debugDescription: "not a game object"))
|
||||
}
|
||||
self.id = id
|
||||
self.type = type
|
||||
self.mine = mine
|
||||
// A log that is there but is not a list of strings is a broken move, not a missing one.
|
||||
if (try? c.decodeNil(forKey: .log)) == false {
|
||||
guard let log = try? c.decode([String].self, forKey: .log) else {
|
||||
throw DecodingError.dataCorrupted(
|
||||
.init(codingPath: c.codingPath, debugDescription: "log is not a list of moves"))
|
||||
}
|
||||
self.log = log
|
||||
} else {
|
||||
self.log = nil
|
||||
}
|
||||
r = (try? c.decodeIfPresent(String.self, forKey: .r)).flatMap(GameEndReason.init(rawValue:))
|
||||
}
|
||||
|
||||
func encode(to encoder: any Encoder) throws {
|
||||
var c = encoder.container(keyedBy: Keys.self)
|
||||
try c.encode(id, forKey: .id)
|
||||
try c.encode(type.rawValue, forKey: .t)
|
||||
try c.encode(mine.rawValue, forKey: .mine)
|
||||
try c.encodeIfPresent(log, forKey: .log)
|
||||
try c.encodeIfPresent(r?.rawValue, forKey: .r)
|
||||
}
|
||||
}
|
||||
|
||||
/// One game envelope (§6.33): a start, a move, or an end or offer.
|
||||
struct GamePart: Hashable, Sendable {
|
||||
enum Kind: String, Sendable {
|
||||
case start = "game"
|
||||
case move = "gmove"
|
||||
case end = "gend"
|
||||
}
|
||||
|
||||
let kind: Kind
|
||||
let game: GameWire
|
||||
|
||||
/// Nil when the envelope cannot be what its kind says: a move without moves, or an end with
|
||||
/// no log or no reason this build knows.
|
||||
init?(kind: String, game: GameWire?) {
|
||||
guard let game, let kind = Kind(rawValue: kind) else { return nil }
|
||||
switch kind {
|
||||
case .start: break
|
||||
case .move: guard let log = game.log, !log.isEmpty else { return nil }
|
||||
case .end: guard game.log != nil, game.r != nil else { return nil }
|
||||
}
|
||||
self.kind = kind
|
||||
self.game = game
|
||||
}
|
||||
}
|
||||
|
||||
extension E2EAttachment {
|
||||
|
||||
/// The `game` object, keys in the order both apps write: `id, t, mine, log, r`.
|
||||
static func gameJSON(
|
||||
id: String, type: GameType, mine: Side, log: [String]? = nil, r: GameEndReason? = nil
|
||||
) -> String {
|
||||
var parts = [
|
||||
"\"id\":\(WireNumber.quoted(id))", "\"t\":\"\(type.rawValue)\"",
|
||||
"\"mine\":\"\(mine.rawValue)\"",
|
||||
]
|
||||
if let log {
|
||||
parts.append("\"log\":[" + log.map(WireNumber.quoted).joined(separator: ",") + "]")
|
||||
}
|
||||
if let r { parts.append("\"r\":\"\(r.rawValue)\"") }
|
||||
return "{" + parts.joined(separator: ",") + "}"
|
||||
}
|
||||
|
||||
/// An invitation, the message the board lives on. Renderable, so it carries `mid`; one to
|
||||
/// one only, so never `gid`.
|
||||
static func encodeGameStart(_ game: GameState, mid: String) -> String {
|
||||
"{\"fnc\":1,\"kind\":\"game\",\"mid\":\(WireNumber.quoted(mid)),\"game\":"
|
||||
+ gameJSON(id: game.id, type: game.type, mine: game.me) + "}"
|
||||
}
|
||||
|
||||
/// A move: the whole log so far, from the side this person plays. Control traffic.
|
||||
static func encodeGameMove(_ game: GameState) -> String {
|
||||
"{\"fnc\":1,\"kind\":\"gmove\",\"ctl\":1,\"game\":"
|
||||
+ gameJSON(id: game.id, type: game.type, mine: game.me, log: game.log) + "}"
|
||||
}
|
||||
|
||||
/// An ending, or a draw offer or its answer, at the log this person has. Control traffic.
|
||||
static func encodeGameEnd(_ game: GameState, _ reason: GameEndReason) -> String {
|
||||
"{\"fnc\":1,\"kind\":\"gend\",\"ctl\":1,\"game\":"
|
||||
+ gameJSON(id: game.id, type: game.type, mine: game.me, log: game.log, r: reason) + "}"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - State
|
||||
|
||||
/// One game in a one-to-one conversation, kept on the message that started it (§6.33).
|
||||
///
|
||||
/// The board, whose move it is and any ending on the board all come from `log`, replayed with
|
||||
/// the engine's capped `Games.replay`. The rest is what travelled in `gend` envelopes.
|
||||
struct GameState: Hashable, Sendable {
|
||||
let id: String
|
||||
let type: GameType
|
||||
/// The side this person plays.
|
||||
let me: Side
|
||||
/// The side that sent the invitation. For a game rebuilt from a move whose start never
|
||||
/// arrived here, the other person, unless what arrived was their decline.
|
||||
let starter: Side
|
||||
/// Every ply so far, the last log both phones agreed on.
|
||||
var log: [String] = []
|
||||
/// How the game ended by message, when it did.
|
||||
var ended: Ended? = nil
|
||||
/// A draw offered and not answered yet.
|
||||
var offer: Offer? = nil
|
||||
/// The two boards disagree (§6.33 rules 2-4). Nothing more is accepted for this game.
|
||||
var outOfStep: Bool = false
|
||||
|
||||
/// An ending that travelled in a `gend`: resigned, a draw agreed, declined or cancelled.
|
||||
struct Ended: Hashable, Sendable {
|
||||
let reason: GameEndReason
|
||||
/// Who sent it. Nil only for a resignation restored from a backup that did not say.
|
||||
let by: Side?
|
||||
}
|
||||
|
||||
/// A draw offer, by the side that had just moved, at the ply count it was made at. Any
|
||||
/// later ply lapses it.
|
||||
struct Offer: Hashable, Sendable {
|
||||
let by: Side
|
||||
let at: Int
|
||||
}
|
||||
|
||||
/// The other person's side.
|
||||
var them: Side { me.opponent }
|
||||
|
||||
/// One line for a reply quote, a pin banner or the conversation list.
|
||||
var summary: String { GameWords.summary(type) }
|
||||
|
||||
/// The side that accepts by playing, or declines.
|
||||
var invitee: Side { starter.opponent }
|
||||
|
||||
/// The position after the log, nil when the log will not replay here (only a damaged
|
||||
/// restore can hold one).
|
||||
var position: GamePosition? { try? Games.replay(type, log) }
|
||||
|
||||
/// Whether the invitee has not moved yet, judged on `log`: until then the invitee may decline
|
||||
/// and the starter may cancel.
|
||||
static func inviteOpen(_ type: GameType, invitee: Side, log: [String]) -> Bool {
|
||||
!log.indices.contains { type.side(ofPly: $0) == invitee }
|
||||
}
|
||||
|
||||
var inviteOpen: Bool { Self.inviteOpen(type, invitee: invitee, log: log) }
|
||||
}
|
||||
|
||||
/// What a peer's envelope did to a game here.
|
||||
enum GameChange: Hashable, Sendable {
|
||||
/// Nothing: a duplicate, a stale log, an envelope after the end, or one not allowed now.
|
||||
case ignored
|
||||
/// An invitation.
|
||||
case invited
|
||||
/// New moves, and the game goes on.
|
||||
case moved
|
||||
/// The game ended, on the board or by message.
|
||||
case ended
|
||||
/// The other person offered a draw.
|
||||
case offered
|
||||
/// The other person turned down this person's offer.
|
||||
case offerDeclined
|
||||
/// The boards disagree; the envelope was refused and the game stops here.
|
||||
case outOfStep
|
||||
}
|
||||
|
||||
extension GameState {
|
||||
|
||||
/// What this person can do besides moving.
|
||||
enum Action: CaseIterable, Sendable {
|
||||
case resign, offerDraw, acceptDraw, declineDraw, decline, cancel
|
||||
|
||||
var reason: GameEndReason {
|
||||
switch self {
|
||||
case .resign: return .resign
|
||||
case .offerDraw: return .drawOffer
|
||||
case .acceptDraw: return .drawAccept
|
||||
case .declineDraw: return .drawDecline
|
||||
case .decline: return .decline
|
||||
case .cancel: return .cancel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A game this person starts.
|
||||
static func starting(id: String, type: GameType, iGoFirst: Bool) -> GameState {
|
||||
let side: Side = iGoFirst ? .first : .second
|
||||
return GameState(id: id, type: type, me: side, starter: side)
|
||||
}
|
||||
|
||||
/// Whether the game is over: ended by message or on the board. An out-of-step game is not
|
||||
/// over, it is stopped; see `outOfStep`.
|
||||
func isOver(at position: GamePosition?) -> Bool {
|
||||
ended != nil || (position.map { Games.outcome($0).isOver } ?? false)
|
||||
}
|
||||
|
||||
/// The state after this person plays `move`, or nil when it is not theirs to play or not
|
||||
/// legal. A new ply lapses any draw offer.
|
||||
func playing(_ move: String) -> GameState? {
|
||||
guard !outOfStep, ended == nil, log.count < type.maxPlies,
|
||||
let position, !Games.outcome(position).isOver, position.sideToMove == me,
|
||||
(try? Games.apply(position, move)) != nil
|
||||
else { return nil }
|
||||
var next = self
|
||||
next.log.append(move)
|
||||
next.offer = nil
|
||||
return next
|
||||
}
|
||||
|
||||
/// The state after `side` sends `reason` at `log`, or nil when that is not allowed now.
|
||||
///
|
||||
/// One rule for both directions: this person's own buttons ask it with `me` and the log they
|
||||
/// have, and a peer's `gend` is judged by it with the log it carried, after that log has been
|
||||
/// brought into step. `position` is this game's position when the caller has it already, so
|
||||
/// a long game is not replayed once per button.
|
||||
func ending(
|
||||
_ reason: GameEndReason, by side: Side, at sent: [String], position known: GamePosition? = nil
|
||||
) -> GameState? {
|
||||
guard !outOfStep, ended == nil, let position = known ?? position,
|
||||
!Games.outcome(position).isOver,
|
||||
sent.count <= log.count, Array(log.prefix(sent.count)) == sent
|
||||
else { return nil }
|
||||
var next = self
|
||||
switch reason {
|
||||
case .resign:
|
||||
next.ended = Ended(reason: .resign, by: side)
|
||||
next.offer = nil
|
||||
case .drawOffer:
|
||||
// From the side that has just moved, at the current log, once at a time, and not
|
||||
// while the invitation is still open: until the invitee's first move there is no
|
||||
// game to call a draw, only one to decline or cancel (DESIGN decision 37).
|
||||
guard offer == nil, sent.count == log.count, position.sideToMove != side,
|
||||
!Self.inviteOpen(type, invitee: invitee, log: sent)
|
||||
else { return nil }
|
||||
next.offer = Offer(by: side, at: sent.count)
|
||||
case .drawAccept:
|
||||
guard let offer, offer.by == side.opponent, offer.at == sent.count,
|
||||
sent.count == log.count
|
||||
else { return nil }
|
||||
next.ended = Ended(reason: .drawAccept, by: side)
|
||||
next.offer = nil
|
||||
case .drawDecline:
|
||||
guard let offer, offer.by == side.opponent, offer.at == sent.count else { return nil }
|
||||
next.offer = nil
|
||||
case .decline:
|
||||
guard side == invitee, Self.inviteOpen(type, invitee: invitee, log: sent) else {
|
||||
return nil
|
||||
}
|
||||
next.ended = Ended(reason: .decline, by: side)
|
||||
next.offer = nil
|
||||
case .cancel:
|
||||
guard side == starter, Self.inviteOpen(type, invitee: invitee, log: sent) else {
|
||||
return nil
|
||||
}
|
||||
next.ended = Ended(reason: .cancel, by: side)
|
||||
next.offer = nil
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/// The state after this person takes `action`, or nil when it is not offered now.
|
||||
func taking(_ action: Action, position: GamePosition? = nil) -> GameState? {
|
||||
ending(action.reason, by: me, at: log, position: position)
|
||||
}
|
||||
|
||||
/// Whether `action` is one this person can take now.
|
||||
///
|
||||
/// Stricter than what is accepted from a peer in one place: a draw is only offered once the
|
||||
/// invitation has been taken up, since before that the invitee can decline and the starter
|
||||
/// can cancel.
|
||||
func offers(_ action: Action, position: GamePosition? = nil) -> Bool {
|
||||
if action == .offerDraw, inviteOpen { return false }
|
||||
if action == .resign, inviteOpen { return false }
|
||||
return taking(action, position: position) != nil
|
||||
}
|
||||
|
||||
private var markedOutOfStep: GameState {
|
||||
var next = self
|
||||
next.outOfStep = true
|
||||
next.offer = nil
|
||||
return next
|
||||
}
|
||||
|
||||
/// Fold one peer envelope into what this phone knows of its game (§6.33, rules 1-4).
|
||||
///
|
||||
/// `known` is nil when this conversation has no game with that id. Returns the game as it now
|
||||
/// stands, nil only when a start was not a game here, and what changed.
|
||||
static func receive(_ part: GamePart, into known: GameState?) -> (GameState?, GameChange) {
|
||||
let wire = part.game
|
||||
let peer = wire.mine
|
||||
if part.kind == .start {
|
||||
// A start for a game already here, rebuilt from a move that beat it, changes nothing.
|
||||
guard known == nil else { return (known, .ignored) }
|
||||
return (GameState(id: wire.id, type: wire.type, me: peer.opponent, starter: peer), .invited)
|
||||
}
|
||||
let sent = wire.log ?? []
|
||||
guard var state = known else {
|
||||
// Rule 1: an unknown game is built from this envelope, its whole log replayed with the
|
||||
// cap. A log that will not replay still makes the card, already out of step, so the
|
||||
// person sees that the boards disagree and is never left with nothing.
|
||||
let starter = part.kind == .end && wire.r == .decline ? peer.opponent : peer
|
||||
var rebuilt = GameState(id: wire.id, type: wire.type, me: peer.opponent, starter: starter)
|
||||
guard (try? Games.replay(wire.type, sent)) != nil else {
|
||||
return (rebuilt.markedOutOfStep, .outOfStep)
|
||||
}
|
||||
rebuilt.log = sent
|
||||
return rebuilt.finishing(part, sent: sent, by: peer, grew: !sent.isEmpty)
|
||||
}
|
||||
// An ended or stopped game takes nothing more.
|
||||
guard !state.outOfStep, !state.isOver(at: state.position) else { return (state, .ignored) }
|
||||
// The same game, and the peer playing the other side of it.
|
||||
guard state.type == wire.type, peer == state.them else {
|
||||
return (state.markedOutOfStep, .outOfStep)
|
||||
}
|
||||
let had = state.log
|
||||
if sent.count <= had.count {
|
||||
// Rule 2: a prefix is a duplicate or stale; anything else disagrees.
|
||||
guard Array(had.prefix(sent.count)) == sent else {
|
||||
return (state.markedOutOfStep, .outOfStep)
|
||||
}
|
||||
guard part.kind == .end else { return (state, .ignored) }
|
||||
return state.finishing(part, sent: sent, by: peer, grew: false)
|
||||
}
|
||||
// Rule 3: what this phone has must be where the new log starts. Rule 4: every new ply is
|
||||
// the sender's to play and legal in turn, replayed from the start with the cap.
|
||||
guard Array(sent.prefix(had.count)) == had,
|
||||
(had.count..<sent.count).allSatisfy({ state.type.side(ofPly: $0) == peer }),
|
||||
(try? Games.replay(state.type, sent)) != nil
|
||||
else { return (state.markedOutOfStep, .outOfStep) }
|
||||
state.log = sent
|
||||
state.offer = nil
|
||||
return state.finishing(part, sent: sent, by: peer, grew: true)
|
||||
}
|
||||
|
||||
/// After the log is in step: what a move changed, or a `gend`'s ending on top of it.
|
||||
private func finishing(
|
||||
_ part: GamePart, sent: [String], by peer: Side, grew: Bool
|
||||
) -> (GameState, GameChange) {
|
||||
let moved: GameChange =
|
||||
grew ? (isOver(at: position) ? .ended : .moved) : .ignored
|
||||
guard part.kind == .end, let reason = part.game.r,
|
||||
let next = ending(reason, by: peer, at: sent)
|
||||
else { return (self, moved) }
|
||||
switch reason {
|
||||
case .drawOffer: return (next, .offered)
|
||||
case .drawDecline: return (next, .offerDeclined)
|
||||
default: return (next, .ended)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stored
|
||||
|
||||
/// The sealed history keeps a game under short keys of its own. The backup has a shape of its own
|
||||
/// too, the one both apps read (`E2EBackupPayload`).
|
||||
extension GameState: Codable {
|
||||
private enum Keys: String, CodingKey { case id, t, me, st, log, end, by, offerBy, offerAt, oos }
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: Keys.self)
|
||||
guard let type = GameType(rawValue: try c.decode(String.self, forKey: .t)),
|
||||
let me = Side(rawValue: try c.decode(String.self, forKey: .me)),
|
||||
let starter = Side(rawValue: try c.decode(String.self, forKey: .st))
|
||||
else {
|
||||
throw DecodingError.dataCorrupted(
|
||||
.init(codingPath: c.codingPath, debugDescription: "not a stored game"))
|
||||
}
|
||||
self.init(id: try c.decode(String.self, forKey: .id), type: type, me: me, starter: starter)
|
||||
log = try c.decodeIfPresent([String].self, forKey: .log) ?? []
|
||||
if let raw = try c.decodeIfPresent(String.self, forKey: .end),
|
||||
let reason = GameEndReason(rawValue: raw)
|
||||
{
|
||||
let by = try c.decodeIfPresent(String.self, forKey: .by).flatMap(Side.init(rawValue:))
|
||||
ended = Ended(reason: reason, by: by)
|
||||
}
|
||||
if let raw = try c.decodeIfPresent(String.self, forKey: .offerBy),
|
||||
let by = Side(rawValue: raw), let at = try c.decodeIfPresent(Int.self, forKey: .offerAt)
|
||||
{
|
||||
offer = Offer(by: by, at: at)
|
||||
}
|
||||
outOfStep = try c.decodeIfPresent(Bool.self, forKey: .oos) ?? false
|
||||
}
|
||||
|
||||
func encode(to encoder: any Encoder) throws {
|
||||
var c = encoder.container(keyedBy: Keys.self)
|
||||
try c.encode(id, forKey: .id)
|
||||
try c.encode(type.rawValue, forKey: .t)
|
||||
try c.encode(me.rawValue, forKey: .me)
|
||||
try c.encode(starter.rawValue, forKey: .st)
|
||||
try c.encode(log, forKey: .log)
|
||||
try c.encodeIfPresent(ended?.reason.rawValue, forKey: .end)
|
||||
try c.encodeIfPresent(ended?.by?.rawValue, forKey: .by)
|
||||
try c.encodeIfPresent(offer?.by.rawValue, forKey: .offerBy)
|
||||
try c.encodeIfPresent(offer?.at, forKey: .offerAt)
|
||||
if outOfStep { try c.encode(true, forKey: .oos) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Words
|
||||
|
||||
/// What a game says, on its card and on the lock screen.
|
||||
enum GameWords {
|
||||
|
||||
/// The game's name at the start of a line.
|
||||
static func title(_ type: GameType) -> String {
|
||||
switch type {
|
||||
case .chess: return "Chess"
|
||||
case .checkers: return "Checkers"
|
||||
case .reversi: return "Reversi"
|
||||
case .tictactoe: return "Tic-tac-toe"
|
||||
}
|
||||
}
|
||||
|
||||
/// The game's name inside a sentence.
|
||||
static func name(_ type: GameType) -> String {
|
||||
type == .tictactoe ? "tic-tac-toe" : type.rawValue
|
||||
}
|
||||
|
||||
/// What each side is called in this game.
|
||||
static func side(_ side: Side, in type: GameType) -> String {
|
||||
switch (type, side) {
|
||||
case (.chess, .first), (.reversi, .second): return "white"
|
||||
case (.chess, .second), (.reversi, .first): return "black"
|
||||
case (.checkers, .first): return "dark"
|
||||
case (.checkers, .second): return "light"
|
||||
case (.tictactoe, .first): return "X"
|
||||
case (.tictactoe, .second): return "O"
|
||||
}
|
||||
}
|
||||
|
||||
/// "You play white".
|
||||
static func yourSide(_ state: GameState) -> String {
|
||||
"You play \(side(state.me, in: state.type))"
|
||||
}
|
||||
|
||||
/// One line for a reply quote, a pin banner or the conversation list.
|
||||
static func summary(_ type: GameType) -> String { "Game: \(name(type))" }
|
||||
|
||||
/// "Alex invited you to play chess".
|
||||
static func invited(from name: String, type: GameType) -> String {
|
||||
"\(name) invited you to play \(self.name(type))"
|
||||
}
|
||||
|
||||
/// The quiet line for the other person's move.
|
||||
static func yourMove(_ type: GameType, with name: String) -> String {
|
||||
"Your move: \(self.name(type)) with \(name)"
|
||||
}
|
||||
|
||||
/// "Sam offers a draw: Chess", said with no sound.
|
||||
static func drawOffered(by name: String, type: GameType) -> String {
|
||||
"\(name) offers a draw: \(title(type))"
|
||||
}
|
||||
|
||||
/// How a game ended, from this person's side, or nil while it goes on.
|
||||
/// "You" and "They", so the same line reads on the card and after
|
||||
/// "Chess with Sam: " on the lock screen.
|
||||
static func result(_ state: GameState, position: GamePosition?) -> String? {
|
||||
if let ended = state.ended {
|
||||
let mine = ended.by == state.me
|
||||
switch ended.reason {
|
||||
case .resign:
|
||||
guard let by = ended.by else { return "Resigned" }
|
||||
return by == state.me ? "You resigned. They won" : "They resigned. You won"
|
||||
case .drawAccept: return "Draw agreed"
|
||||
case .decline: return mine ? "You declined" : "They declined"
|
||||
case .cancel: return mine ? "You cancelled" : "They cancelled"
|
||||
case .drawOffer, .drawDecline: return nil
|
||||
}
|
||||
}
|
||||
guard let position else { return nil }
|
||||
return result(Games.outcome(position), me: state.me, board: position.board)
|
||||
}
|
||||
|
||||
/// How the board ended a game, from the side of the person playing `me`,
|
||||
/// or nil while it goes on.
|
||||
static func result(_ outcome: Outcome, me: Side, board: Board) -> String? {
|
||||
switch outcome {
|
||||
case .ongoing:
|
||||
return nil
|
||||
case .win(let side, let why):
|
||||
let won = side == me
|
||||
switch why {
|
||||
case .checkmate:
|
||||
return won ? "Checkmate. You won" : "Checkmate. They won"
|
||||
case .noMoves:
|
||||
return won ? "You won. They have no moves left" : "They won. You have no moves left"
|
||||
case .moreDiscs:
|
||||
let (a, b) = discs(board, winner: side)
|
||||
return won ? "You won, \(a) discs to \(b)" : "They won, \(a) discs to \(b)"
|
||||
case .threeInARow:
|
||||
return won ? "You won with three in a row" : "They won with three in a row"
|
||||
}
|
||||
case .draw(let why):
|
||||
switch why {
|
||||
case .stalemate: return "Draw by stalemate"
|
||||
case .insufficientMaterial: return "Draw by insufficient material"
|
||||
case .fiftyMoves: return "Draw by fifty moves"
|
||||
case .fortyMoves: return "Draw by forty moves each without a capture"
|
||||
case .repetition: return "Draw by repetition"
|
||||
case .equalDiscs: return "Draw by equal discs"
|
||||
case .fullBoard: return "Draw by full board"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The winner's discs and the loser's.
|
||||
private static func discs(_ board: Board, winner: Side) -> (Int, Int) {
|
||||
let pieces = board.cells.compactMap { $0 }
|
||||
let won = pieces.filter { $0.side == winner }.count
|
||||
return (won, pieces.count - won)
|
||||
}
|
||||
|
||||
/// The line under the board while the game goes on, or stopped.
|
||||
static func status(_ state: GameState, position: GamePosition?) -> String {
|
||||
if state.outOfStep || position == nil { return "Your boards disagree" }
|
||||
if let result = result(state, position: position) { return result }
|
||||
guard let position else { return "Your boards disagree" }
|
||||
if let offer = state.offer {
|
||||
return offer.by == state.me ? "You offered a draw" : "They offered a draw"
|
||||
}
|
||||
if state.log.count >= state.type.maxPlies { return "This game has reached its move limit" }
|
||||
guard position.sideToMove == state.me else { return "Their move" }
|
||||
if Games.legalMoves(position) == ["pass"] { return "Your move: nowhere to place a disc" }
|
||||
if case .chess(let chess) = position, chess.inCheck { return "Check. Your move" }
|
||||
return state.inviteOpen && state.me == state.invitee
|
||||
? "Your move. Play a move to accept" : "Your move"
|
||||
}
|
||||
|
||||
/// The line above the board while the invitation is open.
|
||||
static func invitation(_ state: GameState, them: String) -> String? {
|
||||
guard state.inviteOpen, state.ended == nil, !state.outOfStep else { return nil }
|
||||
return state.starter == state.me ? "You invited \(them)" : "\(them) invited you"
|
||||
}
|
||||
|
||||
/// The line on the lock screen for what a peer's envelope changed, nil for nothing.
|
||||
static func notice(
|
||||
_ change: GameChange, state: GameState, position: GamePosition?, them: String
|
||||
) -> String? {
|
||||
switch change {
|
||||
case .invited: return invited(from: them, type: state.type)
|
||||
case .moved: return yourMove(state.type, with: them)
|
||||
case .offered: return drawOffered(by: them, type: state.type)
|
||||
case .ended:
|
||||
return result(state, position: position).map {
|
||||
"\(title(state.type)) with \(them): \($0)"
|
||||
}
|
||||
case .ignored, .offerDeclined, .outOfStep: return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether that line is said without a sound: everything but an invitation and an ending.
|
||||
static func isQuiet(_ change: GameChange) -> Bool {
|
||||
change != .invited && change != .ended
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,13 @@ struct ContactCard: Equatable {
|
||||
/// Parse a card, from a QR scan or a paste.
|
||||
static func decode(_ text: String) -> ContactCard? {
|
||||
var s = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
// A hello link (SPEC §6.34) carries the address in its fragment. One
|
||||
// that is a hello link and holds no address is refused here, rather
|
||||
// than read as an address with a URL stuck to the front of it.
|
||||
if let link = HelloLink.address(inText: s) {
|
||||
guard case .success(let address) = link else { return nil }
|
||||
s = address
|
||||
}
|
||||
if s.lowercased().hasPrefix("flamenet:") { s = String(s.dropFirst("flamenet:".count)) }
|
||||
|
||||
var key: String?
|
||||
@@ -54,8 +61,15 @@ struct ContactCard: Equatable {
|
||||
let parts = s.split(separator: "@", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
guard parts.count == 2 else { return nil }
|
||||
let fnid = String(parts[0]).trimmingCharacters(in: .whitespaces)
|
||||
let relay = String(parts[1]).trimmingCharacters(in: .whitespaces).lowercased()
|
||||
let typed = String(parts[1]).trimmingCharacters(in: .whitespaces)
|
||||
// A relay's name is ASCII. Checked before lowercasing, which would
|
||||
// fold a Kelvin sign into a "k" and read one host as another.
|
||||
guard typed.unicodeScalars.allSatisfy(\.isASCII) else { return nil }
|
||||
let relay = typed.lowercased()
|
||||
guard !fnid.isEmpty, !relay.isEmpty, relay.contains(".") else { return nil }
|
||||
// The part before the at-sign is an fnid, never a link: any other URL
|
||||
// with an at-sign in it would otherwise read as an address.
|
||||
guard !fnid.contains("/") else { return nil }
|
||||
// A relay is a hostname, never a URL.
|
||||
guard !relay.contains("/"), !relay.contains(":"), !relay.contains("@")
|
||||
else { return nil }
|
||||
|
||||
@@ -137,6 +137,30 @@ enum E2EAttachment {
|
||||
|
||||
/// Which sequence this story belongs to (§6.23).
|
||||
var storySet: StorySet? = nil
|
||||
|
||||
/// Set iff this is a greeting card (§6.31).
|
||||
var card: ChatCard? = nil
|
||||
/// Set iff this is an event, a revision of one, or its cancellation
|
||||
/// (§6.32).
|
||||
var event: ChatEvent? = nil
|
||||
/// Set iff this is somebody's answer to an event (§6.32).
|
||||
var rsvp: RSVPPart? = nil
|
||||
/// Set iff this starts a game, moves in one, or ends it (§6.33). One
|
||||
/// to one only: a group's game envelope is never one of these.
|
||||
var game: GamePart? = nil
|
||||
|
||||
/// Set iff the sender is taking one of their stories down (§6.35).
|
||||
var unpost: StoryUnpost? = nil
|
||||
/// Set iff this edit is about a story's caption, not a message
|
||||
/// (§6.15): `editOf` then names the story by the id replies use.
|
||||
var editsStory: Bool = false
|
||||
}
|
||||
|
||||
/// What an `unpost` names (§6.35): one story by its id, or a whole
|
||||
/// sequence (§6.23) by its set id. Either is enough.
|
||||
struct StoryUnpost: Hashable, Sendable {
|
||||
let sid: String?
|
||||
let set: String?
|
||||
}
|
||||
|
||||
/// A sticker: an id, never an image.
|
||||
@@ -210,8 +234,12 @@ enum E2EAttachment {
|
||||
let att: Att? // present iff the kind carries a blob
|
||||
let mid: String? // this message's stable id (§6.9)
|
||||
let re: Reference? // what it replies to or reacts to (§6.9)
|
||||
/// Which sequence this story belongs to (§6.23).
|
||||
let set: StorySet?
|
||||
/// Which sequence this story belongs to (§6.23), or on an `unpost`
|
||||
/// the id of a whole sequence (§6.35), so it arrives in either shape.
|
||||
let set: SetField?
|
||||
/// §6.35, the story an `unpost` takes down, by the id replies use.
|
||||
/// Lenient, so a `sid` of the wrong type costs only itself.
|
||||
let sid: Lenient<String>?
|
||||
let stk: StickerRef? // sticker only (docs/STICKERS.md)
|
||||
/// A live stream this story embeds (§6.13).
|
||||
let stream: String?
|
||||
@@ -224,7 +252,7 @@ enum E2EAttachment {
|
||||
/// When a screenshot was taken (§6.27), unix seconds.
|
||||
let at: Double?
|
||||
/// Marks control traffic a client may not know the kind of.
|
||||
let ctl: Flag?
|
||||
let ctl: ControlMark?
|
||||
|
||||
/// File envelopes only.
|
||||
let fn: String?
|
||||
@@ -265,6 +293,63 @@ enum E2EAttachment {
|
||||
/// option. 32-bit because the other engine's `Int` is 32 bits.
|
||||
let v: [Int32]?
|
||||
|
||||
/// §6.31, a greeting card. Read leniently: a malformed one is
|
||||
/// absent, never a reason to drop the whole envelope onto the
|
||||
/// plain-text path.
|
||||
let card: Lenient<ChatCard>?
|
||||
/// §6.32, an event.
|
||||
let ev: Lenient<ChatEvent>?
|
||||
/// §6.32, an answer to one.
|
||||
let rsvp: Lenient<RSVPPart.Wire>?
|
||||
/// §6.33, a game: its start, a move, or an end or offer.
|
||||
let game: Lenient<GameWire>?
|
||||
|
||||
/// `set` as a story carries it (an object) or as an `unpost` does (a
|
||||
/// string). Anything else reads as neither, never as a reason to drop
|
||||
/// the whole envelope.
|
||||
enum SetField: Codable {
|
||||
case item(StorySet)
|
||||
case id(String)
|
||||
case neither
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.singleValueContainer()
|
||||
if let item = try? c.decode(StorySet.self) {
|
||||
self = .item(item)
|
||||
} else if let id = try? c.decode(String.self) {
|
||||
self = .id(id)
|
||||
} else {
|
||||
self = .neither
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: any Encoder) throws {
|
||||
var c = encoder.singleValueContainer()
|
||||
switch self {
|
||||
case .item(let item): try c.encode(item)
|
||||
case .id(let id): try c.encode(id)
|
||||
case .neither: try c.encodeNil()
|
||||
}
|
||||
}
|
||||
|
||||
var item: StorySet? {
|
||||
if case .item(let item) = self { return item }
|
||||
return nil
|
||||
}
|
||||
|
||||
var id: String? {
|
||||
if case .id(let id) = self { return id }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes as nil where `T` will not.
|
||||
struct Lenient<T: Codable>: Codable {
|
||||
let value: T?
|
||||
init(from decoder: any Decoder) throws { value = try? T(from: decoder) }
|
||||
func encode(to encoder: any Encoder) throws { try value.encode(to: encoder) }
|
||||
}
|
||||
|
||||
/// A flag written as `1` by this engine and the specification, and as
|
||||
/// `true` by older builds of the Android app.
|
||||
struct Flag: Codable, Equatable {
|
||||
@@ -286,6 +371,29 @@ enum E2EAttachment {
|
||||
}
|
||||
}
|
||||
|
||||
/// §6.10's "carrying ctl" (DESIGN decision 43): any value but 0,
|
||||
/// false or null makes the envelope control, so `"1"` counts as `1`
|
||||
/// does. Absent and null never get here; they decode as no mark.
|
||||
struct ControlMark: Codable, Equatable {
|
||||
let on: Bool
|
||||
init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.singleValueContainer()
|
||||
if let n = try? c.decode(Int.self) {
|
||||
on = n != 0
|
||||
} else if let d = try? c.decode(Double.self) {
|
||||
on = d != 0
|
||||
} else if let b = try? c.decode(Bool.self) {
|
||||
on = b
|
||||
} else {
|
||||
on = true
|
||||
}
|
||||
}
|
||||
func encode(to encoder: any Encoder) throws {
|
||||
var c = encoder.singleValueContainer()
|
||||
try c.encode(on ? 1 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
struct Call: Codable {
|
||||
let id: String
|
||||
let sdp: String?
|
||||
@@ -549,6 +657,25 @@ enum E2EAttachment {
|
||||
re: Reference(mid: mid, author: 0, preview: ""))
|
||||
}
|
||||
|
||||
/// §6.35, take one story down for everyone it went to:
|
||||
/// `{"fnc":1,"kind":"unpost","ctl":1,"st":1,"sid":"<story id>"}`.
|
||||
static func encodeStoryUnpost(sid: String) -> String {
|
||||
"{\"fnc\":1,\"kind\":\"unpost\",\"ctl\":1,\"st\":1,\"sid\":\(WireNumber.quoted(sid))}"
|
||||
}
|
||||
|
||||
/// §6.35, take a whole sequence (§6.23) down by its set id:
|
||||
/// `{"fnc":1,"kind":"unpost","ctl":1,"st":1,"set":"<set id>"}`.
|
||||
static func encodeStoryUnpost(set: String) -> String {
|
||||
"{\"fnc\":1,\"kind\":\"unpost\",\"ctl\":1,\"st\":1,\"set\":\(WireNumber.quoted(set))}"
|
||||
}
|
||||
|
||||
/// §6.15 for a story's caption, or a text story's words:
|
||||
/// `{"fnc":1,"kind":"edit","st":1,"caption":"…","re":{"mid":"<story id>","author":0,"preview":""}}`.
|
||||
static func encodeStoryEdit(_ caption: String, sid: String) -> String {
|
||||
"{\"fnc\":1,\"kind\":\"edit\",\"st\":1,\"caption\":\(WireNumber.quoted(caption)),"
|
||||
+ "\"re\":{\"mid\":\(WireNumber.quoted(sid)),\"author\":0,\"preview\":\"\"}}"
|
||||
}
|
||||
|
||||
/// Build the ratchet plaintext for a pin, or for taking one down (§6.19).
|
||||
static func encodePin(_ mid: String, gid: String? = nil) throws -> String {
|
||||
try assemble(
|
||||
@@ -557,9 +684,11 @@ enum E2EAttachment {
|
||||
}
|
||||
|
||||
/// Build the ratchet plaintext for a sticker.
|
||||
/// A sticker, by pack and id. In a group it carries `gid`, and `gname` as
|
||||
/// a group text does (§6.7).
|
||||
static func encodeSticker(
|
||||
pack: String, id: String,
|
||||
gid: String? = nil, mid: String? = nil
|
||||
gid: String? = nil, mid: String? = nil, groupName: String? = nil
|
||||
) throws -> String {
|
||||
let stk = StickerRef(pack: pack, id: id)
|
||||
let stkJSON = String(decoding: try JSONEncoder().encode(stk), as: UTF8.self)
|
||||
@@ -572,6 +701,10 @@ enum E2EAttachment {
|
||||
if let gid {
|
||||
parts.append(
|
||||
"\"gid\":\(String(decoding: try JSONEncoder().encode(gid), as: UTF8.self))")
|
||||
if let groupName, !groupName.isEmpty {
|
||||
parts.append(
|
||||
"\"gname\":\(String(decoding: try JSONEncoder().encode(groupName), as: UTF8.self))")
|
||||
}
|
||||
}
|
||||
return "{" + parts.joined(separator: ",") + "}"
|
||||
}
|
||||
@@ -847,10 +980,12 @@ enum E2EAttachment {
|
||||
}
|
||||
|
||||
// §6.15, a replacement for the text of an earlier message.
|
||||
// §6.15. With `st` it is about a story's caption, and `re.mid` names
|
||||
// the story by the id replies use.
|
||||
if env.kind == "edit", let re = env.re {
|
||||
return Content(
|
||||
text: env.caption ?? "", attachment: nil, gid: env.gid,
|
||||
editOf: re.mid)
|
||||
editOf: re.mid, editsStory: env.st?.on == true)
|
||||
}
|
||||
|
||||
// §6.19, a pin, or a pin being taken down.
|
||||
@@ -878,6 +1013,47 @@ enum E2EAttachment {
|
||||
return Content(text: "", attachment: nil, gid: env.gid, poll: part)
|
||||
}
|
||||
|
||||
// §6.35, a story taken down by its author: control traffic, decoded
|
||||
// before the generic control branch for the same reason as a poll. It
|
||||
// names one story by `sid` or a whole sequence by `set`, exactly one
|
||||
// of the two: an `unpost` naming both, or neither, is dropped. `gid`
|
||||
// plays no part, since a receiver finds the story by its author and
|
||||
// its id, and nor does a §6.17 `secs`.
|
||||
if env.kind == "unpost" {
|
||||
let sid = env.sid?.value.flatMap { $0.isEmpty ? nil : $0 }
|
||||
let set = env.set?.id.flatMap { $0.isEmpty ? nil : $0 }
|
||||
guard (sid != nil) != (set != nil) else {
|
||||
return Content(text: "", attachment: nil, gid: nil, isUnknownControl: true)
|
||||
}
|
||||
return Content(
|
||||
text: "", attachment: nil, gid: nil, unpost: StoryUnpost(sid: sid, set: set))
|
||||
}
|
||||
|
||||
// §6.32, an answer to an event: control traffic, decoded before the
|
||||
// generic control branch for the same reason as a poll. An answer that
|
||||
// does not name an event, an answer and its place in the sequence is
|
||||
// dropped silently, `ctl` or not: it is never somebody's words.
|
||||
if env.kind == "rsvp" {
|
||||
guard let wire = env.rsvp?.value, let part = RSVPPart(wire: wire) else {
|
||||
return Content(text: "", attachment: nil, gid: nil, isUnknownControl: true)
|
||||
}
|
||||
return Content(text: "", attachment: nil, gid: env.gid, rsvp: part)
|
||||
}
|
||||
|
||||
// §6.33, a move in a game or its end: control traffic, decoded before the
|
||||
// generic control branch for the same reason as a poll. Games are one
|
||||
// to one in this release, so one carrying `gid`, or one that is not a
|
||||
// game, is dropped like any control kind this build does not know.
|
||||
switch env.kind {
|
||||
case "gmove", "gend":
|
||||
guard env.gid == nil, let part = GamePart(kind: env.kind, game: env.game?.value) else {
|
||||
return Content(text: "", attachment: nil, gid: nil, isUnknownControl: true)
|
||||
}
|
||||
return Content(text: "", attachment: nil, gid: nil, game: part)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
// §6.28, a conversation held in memory by both sides.
|
||||
if env.kind == "otr" {
|
||||
return Content(
|
||||
@@ -902,6 +1078,30 @@ enum E2EAttachment {
|
||||
return Content(text: "", attachment: nil, gid: nil, isUnknownControl: true)
|
||||
}
|
||||
|
||||
// §6.31 and §6.32, a card or an event: renderable, so below the
|
||||
// control branch, which is what makes a `ctl` on either one control.
|
||||
// One that will not draw falls through to the unsupported placeholder.
|
||||
// A §6.17 `secs` on a card, an event or a game is ignored in 0.32
|
||||
// (DESIGN decision 46); the chat's own timer still applies to them.
|
||||
if env.kind == "card", let card = env.card?.value {
|
||||
return Content(
|
||||
text: "", attachment: nil, gid: env.gid, mid: env.mid,
|
||||
isForwarded: env.fwd?.on == true, card: card)
|
||||
}
|
||||
if env.kind == "event", let event = env.ev?.value, event.isValid {
|
||||
return Content(
|
||||
text: "", attachment: nil, gid: env.gid, mid: env.mid,
|
||||
isForwarded: env.fwd?.on == true, event: event)
|
||||
}
|
||||
// §6.33, a game's start, which its board is drawn on. One to one only:
|
||||
// in a group, or when it is not a game, it falls through to the
|
||||
// unsupported placeholder below.
|
||||
if env.kind == "game", env.gid == nil,
|
||||
let part = GamePart(kind: env.kind, game: env.game?.value)
|
||||
{
|
||||
return Content(text: "", attachment: nil, gid: nil, mid: env.mid, game: part)
|
||||
}
|
||||
|
||||
// Group text: body in `caption`, no attachment (§6.7).
|
||||
if env.kind == "text" {
|
||||
return Content(
|
||||
@@ -945,6 +1145,6 @@ enum E2EAttachment {
|
||||
text: env.caption ?? "", attachment: ref, gid: env.gid,
|
||||
mid: env.mid, re: env.re, streamURL: env.stream,
|
||||
isForwarded: env.fwd?.on == true, lifetimeSeconds: env.secs,
|
||||
storySet: env.set)
|
||||
storySet: env.set?.item)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import FlamenetGames
|
||||
import Foundation
|
||||
|
||||
/// The plaintext inside a §16 backup, in the shape both apps agree on.
|
||||
@@ -216,7 +217,11 @@ enum E2EBackupPayload {
|
||||
}
|
||||
|
||||
private static func message(_ m: E2EMessage) -> [String: Any] {
|
||||
var out: [String: Any] = ["mine": m.isMine, "at": millis(m.date), "text": m.text]
|
||||
// A card or an event keeps its words in `card` or `ev` only, so a
|
||||
// sealed card's words never sit in `text` (§6.31, §6.32). A game has
|
||||
// none (§6.33).
|
||||
let text = m.card != nil || m.event != nil || m.game != nil ? "" : m.text
|
||||
var out: [String: Any] = ["mine": m.isMine, "at": millis(m.date), "text": text]
|
||||
if let mid = m.mid { out["mid"] = mid }
|
||||
if let a = m.attachment { out["att"] = attachment(a) }
|
||||
if !m.reactions.isEmpty {
|
||||
@@ -235,9 +240,111 @@ enum E2EBackupPayload {
|
||||
if let s = m.sticker { out["stk"] = ["pack": s.pack, "id": s.id] }
|
||||
if let l = m.link { out["lnk"] = ["url": l.url, "title": l.title, "desc": l.desc] }
|
||||
if let d = m.delivery { out["delivery"] = d.rawValue }
|
||||
portalKinds(card: m.card, event: m.event, into: &out)
|
||||
if let g = m.game { out["game"] = gameObject(g) }
|
||||
return out
|
||||
}
|
||||
|
||||
/// A game (§6.33) in the shape both apps read: `id`, `t`, `me` (the side
|
||||
/// this person plays) and the whole `log`, with `r`, the ending a `gend`
|
||||
/// brought, if any, and `rby`, the side that sent it. A draw offer still
|
||||
/// waiting for an answer is not an ending and is not written.
|
||||
private static func gameObject(_ g: GameState) -> [String: Any] {
|
||||
var out: [String: Any] = [
|
||||
"id": g.id, "t": g.type.rawValue, "me": g.me.rawValue, "log": g.log,
|
||||
]
|
||||
if let ended = g.ended {
|
||||
out["r"] = ended.reason.rawValue
|
||||
if let by = ended.by { out["rby"] = by.rawValue }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/// The other half. The starter is whoever wrote the message, so a restored
|
||||
/// game still knows who may decline and who may cancel; a decline or a
|
||||
/// cancel written without `rby` came from the only side that could send
|
||||
/// it. A log that will not replay is kept as it was and drawn out of step.
|
||||
private static func readGame(_ m: [String: Any], mine: Bool) -> GameState? {
|
||||
guard let g = m["game"] as? [String: Any], let id = g["id"] as? String, !id.isEmpty,
|
||||
let type = (g["t"] as? String).flatMap(GameType.init(rawValue:)),
|
||||
let me = (g["me"] as? String).flatMap(Side.init(rawValue:))
|
||||
else { return nil }
|
||||
var state = GameState(id: id, type: type, me: me, starter: mine ? me : me.opponent)
|
||||
state.log = g["log"] as? [String] ?? []
|
||||
if let reason = (g["r"] as? String).flatMap(GameEndReason.init(rawValue:)), reason.ends {
|
||||
let written = (g["rby"] as? String).flatMap(Side.init(rawValue:))
|
||||
let only: Side? =
|
||||
reason == .decline ? state.invitee : reason == .cancel ? state.starter : nil
|
||||
state.ended = GameState.Ended(reason: reason, by: written ?? only)
|
||||
}
|
||||
if state.position == nil { state.outOfStep = true }
|
||||
return state
|
||||
}
|
||||
|
||||
/// A card (§6.31) and an event (§6.32) in the shape both apps read: `card` and
|
||||
/// `ev` are the wire objects as they travel, the event's latest accepted
|
||||
/// revision, beside the organizer in `org` and every answer in `rsvp`.
|
||||
private static func portalKinds(
|
||||
card: ChatCard?, event: EventState?, into out: inout [String: Any]
|
||||
) {
|
||||
if let card {
|
||||
var c: [String: Any] = ["d": card.d, "msg": card.msg]
|
||||
if let to = card.to { c["to"] = to }
|
||||
if let from = card.from { c["from"] = from }
|
||||
if let open = card.open { c["open"] = open }
|
||||
out["card"] = c
|
||||
}
|
||||
if let event {
|
||||
let ev = event.event
|
||||
var e: [String: Any] = ["id": ev.id, "rev": ev.rev, "title": ev.title]
|
||||
if let start = ev.start { e["start"] = start }
|
||||
if let end = ev.end { e["end"] = end }
|
||||
if let day = ev.day { e["day"] = day }
|
||||
if let days = ev.days { e["days"] = days }
|
||||
if let tz = ev.tz { e["tz"] = tz }
|
||||
if let place = ev.place { e["place"] = place }
|
||||
if let note = ev.note { e["note"] = note }
|
||||
if ev.cancel { e["cancel"] = 1 }
|
||||
out["ev"] = e
|
||||
out["org"] = event.organizer
|
||||
if !event.rsvps.isEmpty {
|
||||
out["rsvp"] = Dictionary(
|
||||
uniqueKeysWithValues: event.rsvps.map {
|
||||
(String($0.key), ["r": $0.value.r.rawValue, "n": $0.value.n] as [String: Any])
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The other half: the same objects read back through the same lenient
|
||||
/// decoders the wire uses, so a field one app writes and the other does
|
||||
/// not know is ignored, never fatal. An event that will not draw is
|
||||
/// dropped with nothing invented in its place.
|
||||
private static func readPortalKinds(_ m: [String: Any]) -> (ChatCard?, EventState?) {
|
||||
let card = (m["card"] as? [String: Any]).flatMap { object -> ChatCard? in
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: object) else { return nil }
|
||||
return try? JSONDecoder().decode(ChatCard.self, from: data)
|
||||
}
|
||||
let event = (m["ev"] as? [String: Any]).flatMap { object -> EventState? in
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: object),
|
||||
let ev = try? JSONDecoder().decode(ChatEvent.self, from: data), ev.isValid
|
||||
else { return nil }
|
||||
var rsvps: [Int: EventRSVP] = [:]
|
||||
for (who, value) in m["rsvp"] as? [String: [String: Any]] ?? [:] {
|
||||
guard let id = Int(who), let raw = value["r"] as? String,
|
||||
let answer = RSVPAnswer(rawValue: raw), let n = value["n"] as? Int
|
||||
else { continue }
|
||||
rsvps[id] = EventRSVP(r: answer, n: n)
|
||||
}
|
||||
// A restored organizer is taken as settled: the file is what this
|
||||
// person's phone had already agreed to.
|
||||
return EventState(
|
||||
event: ev, organizer: m["org"] as? Int ?? 0, organizerConfirmed: true,
|
||||
rsvps: rsvps)
|
||||
}
|
||||
return (card, event)
|
||||
}
|
||||
|
||||
private static func reference(_ r: E2EAttachment.Reference) -> [String: Any] {
|
||||
["mid": r.mid, "author": r.author, "preview": r.preview]
|
||||
}
|
||||
@@ -263,11 +370,14 @@ enum E2EBackupPayload {
|
||||
url: url, title: l["title"] as? String ?? "", desc: l["desc"] as? String ?? "")
|
||||
}
|
||||
if let d = m["delivery"] as? String { out.delivery = E2EDelivery(rawValue: d) }
|
||||
(out.card, out.event) = readPortalKinds(m)
|
||||
out.game = readGame(m, mine: out.isMine)
|
||||
}
|
||||
|
||||
private static func groupMessage(_ m: E2EGroupMessage) -> [String: Any] {
|
||||
var out: [String: Any] = [
|
||||
"mine": m.isMine, "at": millis(m.date), "text": m.text, "author": m.authorID,
|
||||
"mine": m.isMine, "at": millis(m.date),
|
||||
"text": m.card != nil || m.event != nil ? "" : m.text, "author": m.authorID,
|
||||
]
|
||||
if let a = m.attachment { out["att"] = attachment(a) }
|
||||
if let mid = m.mid { out["mid"] = mid }
|
||||
@@ -283,6 +393,7 @@ enum E2EBackupPayload {
|
||||
if !m.privateReactions.isEmpty {
|
||||
out["reactionsPrivate"] = m.privateReactions.mapValues { Array($0).sorted() }
|
||||
}
|
||||
portalKinds(card: m.card, event: m.event, into: &out)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -315,6 +426,9 @@ enum E2EBackupPayload {
|
||||
// Which item of a set this is. Without it a multi-part story restores
|
||||
// as loose pictures that no longer know they belong together.
|
||||
if let set = s.set { out["set"] = ["id": set.id, "i": set.i, "n": set.n] }
|
||||
// A changed caption stays marked as changed after a restore (§6.15),
|
||||
// under the name a message's edit uses.
|
||||
if let edited = s.editedAt { out["edited"] = millis(edited) }
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -625,6 +739,7 @@ enum E2EBackupPayload {
|
||||
if let r = m["reactionsPrivate"] as? [String: [Int]] {
|
||||
out.privateReactions = r.mapValues { Set($0) }
|
||||
}
|
||||
(out.card, out.event) = readPortalKinds(m)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -668,6 +783,7 @@ enum E2EBackupPayload {
|
||||
out.set = E2EAttachment.StorySet(
|
||||
id: setID, i: set["i"] as? Int ?? 0, n: set["n"] as? Int ?? 1)
|
||||
}
|
||||
if let edited = s["edited"] as? Int { out.editedAt = date(edited) }
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -164,12 +164,12 @@ final class E2EClient {
|
||||
path: "/e2e/log/consistency")
|
||||
}
|
||||
|
||||
/// Section 8.11: the separate client for reads that name nobody. Its own
|
||||
/// Section 8.13: the separate client for reads that name nobody. Its own
|
||||
/// session, so nothing here ever shares a connection with it.
|
||||
private(set) lazy var anonymous = AnonymousRelayClient(
|
||||
base: endpoint(""), protocolClasses: protocolClasses)
|
||||
|
||||
/// Section 8.11: this account's own devices, with their registration and
|
||||
/// Section 8.13: this account's own devices, with their registration and
|
||||
/// last-poll times, from the route that answers about the caller only.
|
||||
func ownDevicesWithTimes() async throws -> E2EDevicesResponse {
|
||||
try await getJSON(endpoint("/e2e/devices/me"), path: "/e2e/devices/me")
|
||||
@@ -188,7 +188,7 @@ final class E2EClient {
|
||||
return cert
|
||||
}
|
||||
|
||||
/// Section 8.12, stage B2: add and revoke reusable contact links, by their
|
||||
/// Section 8.14, stage B2: add and revoke reusable contact links, by their
|
||||
/// SHA-256 only. Answers how many the account now holds.
|
||||
@discardableResult
|
||||
func setContactLinks(add: [String], revoke: [String]) async throws -> Int {
|
||||
|
||||
@@ -36,6 +36,12 @@ enum E2EEnvelopeRoute: String, CaseIterable, Sendable {
|
||||
case poll
|
||||
/// A contact's bridge offer that §24.5 keeps, stored for review.
|
||||
case bridgeOffer
|
||||
/// Somebody's answer to an event (§6.32). Control traffic, folded onto
|
||||
/// the event's card and never notified.
|
||||
case rsvp
|
||||
/// A story taken down by its author (§6.35). Control traffic: it removes
|
||||
/// the story and never notifies.
|
||||
case unpost
|
||||
/// Control traffic of a kind this build does not handle (`ctl: 1`).
|
||||
case unknownControl
|
||||
/// The sender's profile picture.
|
||||
@@ -54,6 +60,14 @@ enum E2EEnvelopeRoute: String, CaseIterable, Sendable {
|
||||
case mediaStory
|
||||
/// A sticker, which is a message and never a bubble of text.
|
||||
case sticker
|
||||
/// A greeting card (§6.31), a message drawn as a card.
|
||||
case card
|
||||
/// An event (§6.32), or a revision folded onto the card it revises.
|
||||
case event
|
||||
/// A game's start, a move in it, or its end (§6.33), folded onto the board
|
||||
/// on the message that started it. One to one only; the parser never hands
|
||||
/// this branch a group's game envelope.
|
||||
case game
|
||||
/// Call signalling, for the call manager (§6.8).
|
||||
case callSignal
|
||||
/// A reaction, folded onto whatever it is about (§6.14).
|
||||
@@ -87,12 +101,17 @@ enum E2EEnvelopeRoute: String, CaseIterable, Sendable {
|
||||
.disappearTimer,
|
||||
.poll,
|
||||
.bridgeOffer,
|
||||
.rsvp,
|
||||
.unpost,
|
||||
.unknownControl,
|
||||
.avatar,
|
||||
.textStory,
|
||||
.groupStory,
|
||||
.mediaStory,
|
||||
.sticker,
|
||||
.card,
|
||||
.event,
|
||||
.game,
|
||||
.callSignal,
|
||||
.reaction,
|
||||
.edit,
|
||||
@@ -160,6 +179,10 @@ enum E2EEnvelopeRoute: String, CaseIterable, Sendable {
|
||||
return content.poll != nil
|
||||
case .bridgeOffer:
|
||||
return content.bridgeLine != nil
|
||||
case .rsvp:
|
||||
return content.rsvp != nil
|
||||
case .unpost:
|
||||
return content.unpost != nil
|
||||
case .unknownControl:
|
||||
return content.isUnknownControl
|
||||
case .avatar:
|
||||
@@ -172,6 +195,12 @@ enum E2EEnvelopeRoute: String, CaseIterable, Sendable {
|
||||
return content.attachment?.kind == .story
|
||||
case .sticker:
|
||||
return content.sticker != nil
|
||||
case .card:
|
||||
return content.card != nil
|
||||
case .event:
|
||||
return content.event != nil
|
||||
case .game:
|
||||
return content.game != nil
|
||||
case .callSignal:
|
||||
return content.call != nil
|
||||
case .reaction:
|
||||
|
||||
@@ -17,7 +17,7 @@ extension E2EInbox {
|
||||
/// How the first message to somebody is authorised when this account holds
|
||||
/// none of their capabilities.
|
||||
enum FirstContact {
|
||||
/// A one-time contact link's delivery token (section 8.12).
|
||||
/// A one-time contact link's delivery token (section 8.14).
|
||||
case token(Data)
|
||||
/// An introduction from `POST /e2e/intro` (section 14.5).
|
||||
case intro(E2EIntroAnswer)
|
||||
@@ -249,7 +249,7 @@ extension E2EInbox {
|
||||
String(localized: "\(handle) (not verified yet)")
|
||||
}
|
||||
|
||||
/// A one-time link (section 8.12): a delivery token this account mints
|
||||
/// A one-time link (section 8.14): a delivery token this account mints
|
||||
/// for it and uploads among its others, so the relay holds nothing that
|
||||
/// marks it as a link. Whoever scans it can send one sealed first
|
||||
/// message. Works on any relay that takes delivery tokens.
|
||||
@@ -272,7 +272,7 @@ extension E2EInbox {
|
||||
store.oneTimeLinkTokens = []
|
||||
}
|
||||
|
||||
/// A reusable link (section 8.12, stage B2): good until revoked, at most
|
||||
/// A reusable link (section 8.14, stage B2): good until revoked, at most
|
||||
/// five. The relay stores only its SHA-256.
|
||||
func makeReusableLink(handle: String?) async throws -> ContactLink {
|
||||
let capability = ContactLink.freshCapability()
|
||||
@@ -319,7 +319,7 @@ extension E2EInbox {
|
||||
}
|
||||
if let handle = link.handle, !handle.isEmpty, certifiedHandle(for: link.userID) == nil {
|
||||
// Shown as unverified until their first reply carries a
|
||||
// certificate (section 8.12), which `adoptHandleCertificate`
|
||||
// certificate (section 8.14), which `adoptHandleCertificate`
|
||||
// writes over this. The same words as Android's chat title.
|
||||
updateBuddyNames([link.userID: Self.unverifiedName(handle)])
|
||||
}
|
||||
|
||||
@@ -444,12 +444,12 @@ struct E2ERelayDescriptor: Decodable {
|
||||
/// certificates (C2) and sealed first contact with no named fallback
|
||||
/// (C3).
|
||||
var introductions: Bool? = nil
|
||||
/// Section 8.11, stage D1: `/e2e/anon/presence|devices|bundles` and
|
||||
/// Section 8.13, stage D1: `/e2e/anon/presence|devices|bundles` and
|
||||
/// `GET /e2e/devices/me`. Turns on reads that name nobody (D2).
|
||||
var anonymousReads: Bool? = nil
|
||||
/// Section 8.12, stage B2: `POST /e2e/contact-links`, reusable links.
|
||||
/// Section 8.14, stage B2: `POST /e2e/contact-links`, reusable links.
|
||||
var contactLinks: Bool? = nil
|
||||
/// Section 8.12, stage B1: the Listed / Unlisted setting on
|
||||
/// Section 8.14, stage B1: the Listed / Unlisted setting on
|
||||
/// `/messenger/me` (`discoverable`).
|
||||
var unlisted: Bool? = nil
|
||||
/// `POST /api/flamenet/v1/search`: web search the relay asks of its
|
||||
@@ -627,7 +627,7 @@ struct E2EIntroAnswer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Section 8.11, the challenge an introduction by handle has to answer.
|
||||
/// Section 8.13, the challenge an introduction by handle has to answer.
|
||||
struct E2EIntroChallenge: Decodable {
|
||||
let challenge: String
|
||||
let bits: Int
|
||||
|
||||
@@ -710,7 +710,7 @@ final class KeychainE2EStore {
|
||||
}
|
||||
|
||||
/// One-time contact links this account made and has not revoked: the
|
||||
/// delivery tokens they carry (section 8.12), base64.
|
||||
/// delivery tokens they carry (section 8.14), base64.
|
||||
var oneTimeLinkTokens: [String] {
|
||||
get { suite.stringArray(forKey: "one_time_links") ?? [] }
|
||||
set { suite.set(newValue, forKey: "one_time_links") }
|
||||
|
||||
@@ -93,7 +93,7 @@ struct DeviceLinkCode: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Section 8.12, a contact link: how somebody reaches an account without a
|
||||
/// Section 8.14, a contact link: how somebody reaches an account without a
|
||||
/// username search. One-time links carry a delivery token (`t`), reusable
|
||||
/// ones a link capability (`l`); exactly one of the two.
|
||||
struct ContactLink: Equatable {
|
||||
@@ -232,7 +232,7 @@ struct ContactLink: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Section 8.11, the proof of work on an introduction by handle: the smallest
|
||||
/// Section 8.13, the proof of work on an introduction by handle: the smallest
|
||||
/// decimal nonce for which SHA-256(`<challenge>.<nonce>`) starts with at least
|
||||
/// `bits` zero bits.
|
||||
enum IntroProofOfWork {
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import Foundation
|
||||
|
||||
/// Hello links (SPEC §6.34): an address in the FRAGMENT of a link on the
|
||||
/// project's site, `https://flamenetmessenger.com/hi#<address>`.
|
||||
///
|
||||
/// A browser never sends the part after `#`, so the web server that serves the
|
||||
/// page never receives the address. The page hands it to the app as
|
||||
/// `flamenetmessenger://hi#<address>`, and the app does exactly what a scanned
|
||||
/// code does: it fills in Home's add-by-address screen and waits. Nothing is
|
||||
/// sent until the person chooses to.
|
||||
enum HelloLink {
|
||||
|
||||
/// Where every hello link points, whichever relay the address lives on.
|
||||
static let page = URL(string: "https://flamenetmessenger.com/hi")!
|
||||
|
||||
/// The scheme the page opens the app with, registered in project.yml.
|
||||
static let scheme = "flamenetmessenger"
|
||||
|
||||
/// Paths of the page, in English and in Spanish, exactly: in their own
|
||||
/// case and with no trailing slash (DESIGN decision 35).
|
||||
private static let pagePaths: Set<String> = ["/hi", "/es/hi"]
|
||||
|
||||
/// The hello link for `address`, or nil when it is not an address.
|
||||
static func link(for address: String) -> URL? {
|
||||
guard let address = canonicalAddress(address) else { return nil }
|
||||
return URL(string: page.absoluteString + "#" + address)
|
||||
}
|
||||
|
||||
/// Why a link was refused, in words a person can act on.
|
||||
enum Problem: Error, Equatable {
|
||||
/// Not a hello link at all.
|
||||
case notAHelloLink
|
||||
/// A hello link with nothing after the `#`.
|
||||
case noAddress
|
||||
/// A hello link whose `#` holds something that is not an address.
|
||||
case notAnAddress
|
||||
|
||||
var message: String {
|
||||
switch self {
|
||||
case .notAHelloLink:
|
||||
return "That link isn't a hello link, so there was nothing to open."
|
||||
case .noAddress:
|
||||
return "That hello link has no address in it. Ask them to send it again."
|
||||
case .notAnAddress:
|
||||
return "That hello link doesn't hold a Flamenet Messenger address. It may have "
|
||||
+ "been cut short when it was copied. Ask them to send it again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The address in a link the app was opened with.
|
||||
static func address(in url: URL) -> Result<String, Problem> {
|
||||
address(inText: url.absoluteString) ?? .failure(.notAHelloLink)
|
||||
}
|
||||
|
||||
/// The address in a hello link written out as text, from a scan or a
|
||||
/// paste. Nil when the text is not a hello link at all, so the caller can
|
||||
/// try the other forms an address comes in.
|
||||
static func address(inText text: String) -> Result<String, Problem>? {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let lowered = asciiLowercased(trimmed)
|
||||
// Text that starts like one of ours and does not even parse is a hello
|
||||
// link that broke on the way, not something to read some other way.
|
||||
let startsLikeOne =
|
||||
lowered.hasPrefix("https://flamenetmessenger.com/hi")
|
||||
|| lowered.hasPrefix("https://flamenetmessenger.com/es/hi")
|
||||
|| lowered.hasPrefix(scheme + ":")
|
||||
guard let components = URLComponents(string: trimmed),
|
||||
let parsedScheme = components.scheme.map(asciiLowercased)
|
||||
else { return startsLikeOne ? .failure(.notAnAddress) : nil }
|
||||
let host = components.host.map(asciiLowercased) ?? ""
|
||||
|
||||
// The heads exactly, as both apps and the /hi page read them: no
|
||||
// query string, and the path as written. Scheme and host are matched
|
||||
// in any case, as they are everywhere.
|
||||
switch parsedScheme {
|
||||
case "https":
|
||||
guard host == "flamenetmessenger.com", pagePaths.contains(components.path),
|
||||
components.query == nil
|
||||
else { return nil }
|
||||
case scheme:
|
||||
guard host == "hi", components.path.isEmpty, components.query == nil else {
|
||||
return .failure(.notAHelloLink)
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
// The project's page, or the app's own link, and nothing dressed up
|
||||
// as either.
|
||||
guard components.user == nil, components.password == nil, components.port == nil else {
|
||||
return .failure(.notAnAddress)
|
||||
}
|
||||
|
||||
guard let encoded = components.percentEncodedFragment, !encoded.isEmpty else {
|
||||
return .failure(.noAddress)
|
||||
}
|
||||
// Far past any real address, as the relay's /hi page has it.
|
||||
guard encoded.count <= 400 else { return .failure(.notAnAddress) }
|
||||
// Decoded once. Anything still encoded after that is not an address,
|
||||
// and the strict check below refuses the `%` it leaves.
|
||||
guard let fragment = encoded.removingPercentEncoding,
|
||||
let address = canonicalAddress(fragment)
|
||||
else { return .failure(.notAnAddress) }
|
||||
return .success(address)
|
||||
}
|
||||
|
||||
/// What opening the app with `url` does: the address to hand Home's
|
||||
/// search box, the way a scanned code does, and what, if anything, to
|
||||
/// say. The person then reads who it is and decides; nothing is sent
|
||||
/// until they do, and first contact goes the way it always does.
|
||||
static func landing(for url: URL, signedIn: Bool) -> (address: String?, notice: LinkNotice?) {
|
||||
switch address(in: url) {
|
||||
case .success(let address):
|
||||
// Kept for after sign-in, when Home appears and takes it.
|
||||
let notice =
|
||||
signedIn
|
||||
? nil
|
||||
: LinkNotice(
|
||||
title: "Hello link",
|
||||
message: "Sign in, and \(address) will be waiting in Home's search.")
|
||||
return (address, notice)
|
||||
case .failure(let problem):
|
||||
return (nil, LinkNotice(title: "Couldn't open that link", message: problem.message))
|
||||
}
|
||||
}
|
||||
|
||||
/// What the in-app scanners accept: a hello link, an address card from
|
||||
/// this app or an older one (`flamenet:<address>?…`), or a bare address,
|
||||
/// each checked as strictly as a hello link is.
|
||||
///
|
||||
/// Read here rather than through `ContactCard.decode`, which trims round
|
||||
/// the at-sign for somebody typing: a code is not typed, and an address
|
||||
/// with a space in it is not an address (DESIGN decision 41).
|
||||
static func scannedAddress(_ text: String) -> String? {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let link = address(inText: trimmed) {
|
||||
guard case .success(let address) = link else { return nil }
|
||||
return address
|
||||
}
|
||||
var s = trimmed
|
||||
if asciiLowercased(String(s.prefix(9))) == "flamenet:" { s = String(s.dropFirst(9)) }
|
||||
// An older card's query, `k=` and `u=`, set aside unread.
|
||||
if let query = s.firstIndex(of: "?") { s = String(s[..<query]) }
|
||||
return canonicalAddress(s)
|
||||
}
|
||||
|
||||
// MARK: - The address itself
|
||||
|
||||
/// `fnid:` + 52 characters of the FNID alphabet + `@` + a hostname, with
|
||||
/// the hostname lowercased; nil for anything else.
|
||||
///
|
||||
/// Strict on purpose, and the same rule on both apps and the relay's page
|
||||
/// (DESIGN decision 21). A link anyone can publish is input from anyone,
|
||||
/// and whatever passes here lands in a search box and a screen that names
|
||||
/// it.
|
||||
static func canonicalAddress(_ text: String) -> String? {
|
||||
// ASCII, checked before anything is lowercased: Unicode lowercasing
|
||||
// folds a Kelvin sign into a "k", which would turn one host into
|
||||
// another (DESIGN decision 41).
|
||||
guard text.unicodeScalars.allSatisfy(\.isASCII) else { return nil }
|
||||
let parts = text.split(separator: "@", omittingEmptySubsequences: false)
|
||||
guard parts.count == 2 else { return nil }
|
||||
let fnid = String(parts[0])
|
||||
let host = asciiLowercased(String(parts[1]))
|
||||
guard fnid.hasPrefix("fnid:"), FNID.decodeAddress(fnid) != nil, isHostname(host)
|
||||
else { return nil }
|
||||
return fnid + "@" + host
|
||||
}
|
||||
|
||||
/// A-Z to a-z and nothing else, so no other character can become one
|
||||
/// of them.
|
||||
static func asciiLowercased(_ text: String) -> String {
|
||||
String(
|
||||
String.UnicodeScalarView(
|
||||
text.unicodeScalars.map { scalar in
|
||||
guard (0x41...0x5A).contains(scalar.value),
|
||||
let lower = Unicode.Scalar(scalar.value + 0x20)
|
||||
else { return scalar }
|
||||
return lower
|
||||
}))
|
||||
}
|
||||
|
||||
/// Letters, digits, hyphens and dots, with at least one dot: a relay's
|
||||
/// name, never a URL, a port or a path. The rule the other app and the
|
||||
/// relay's /hi page apply, word for word (DESIGN decision 21), so a link
|
||||
/// one of them accepts is not refused by another.
|
||||
static func isHostname(_ host: String) -> Bool {
|
||||
guard !host.isEmpty, host.contains(".") else { return false }
|
||||
return host.unicodeScalars.allSatisfy { scalar in
|
||||
(scalar.value >= 0x61 && scalar.value <= 0x7A) // a-z
|
||||
|| (scalar.value >= 0x30 && scalar.value <= 0x39) // 0-9
|
||||
|| scalar == "-" || scalar == "."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An alert about a link the app was opened with.
|
||||
struct LinkNotice: Equatable {
|
||||
let title: String
|
||||
let message: String
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
/// Section 8.12, Listed and Unlisted (decision DB1), and the choice offered at
|
||||
/// Section 8.14, Listed and Unlisted (decision DB1), and the choice offered at
|
||||
/// sign-up (DB2).
|
||||
///
|
||||
/// The words matter and the spec pins them: Unlisted means people cannot
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import FlamenetGames
|
||||
import Foundation
|
||||
|
||||
/// A single instant message in a thread.
|
||||
@@ -44,6 +45,13 @@ struct Message: Identifiable, Hashable {
|
||||
/// True when the sender was passing on somebody else's words (§6.16).
|
||||
let isForwarded: Bool
|
||||
|
||||
/// Set iff this is a greeting card.
|
||||
let card: ChatCard?
|
||||
/// Set iff this is an event, with its revisions and answers folded in.
|
||||
let event: EventState?
|
||||
/// Set iff this message started a game, with every move since (§6.33).
|
||||
let game: GameState?
|
||||
|
||||
init(
|
||||
id: Int, who: String, body: String, timeText: String, isMine: Bool,
|
||||
receivedAt: Date = Date(), attachment: E2EAttachmentRef? = nil,
|
||||
@@ -54,8 +62,14 @@ struct Message: Identifiable, Hashable {
|
||||
link: LinkPreview? = nil,
|
||||
replyToStory: Bool = false,
|
||||
editedAt: Date? = nil,
|
||||
isForwarded: Bool = false
|
||||
isForwarded: Bool = false,
|
||||
card: ChatCard? = nil,
|
||||
event: EventState? = nil,
|
||||
game: GameState? = nil
|
||||
) {
|
||||
self.card = card
|
||||
self.event = event
|
||||
self.game = game
|
||||
self.replyToStory = replyToStory
|
||||
self.editedAt = editedAt
|
||||
self.isForwarded = isForwarded
|
||||
@@ -98,3 +112,43 @@ extension Message {
|
||||
return thread.first(where: { $0.mid == quoted.mid })?.id
|
||||
}
|
||||
}
|
||||
|
||||
extension Message {
|
||||
/// Whether this is a card still sealed on this phone.
|
||||
func isSealedCard(now: Date = Date()) -> Bool {
|
||||
card?.isSealed(isMine: isMine, receivedAt: receivedAt, now: now) ?? false
|
||||
}
|
||||
|
||||
/// One line about a card, an event or a game, for a reply quote, a pin
|
||||
/// banner or a conversation list: the front's title, the event's, or the
|
||||
/// game's name, never the words inside a card. Nil for anything else.
|
||||
var kindSummary: String? {
|
||||
card?.summary ?? event?.event.summary ?? game?.summary
|
||||
}
|
||||
|
||||
/// What Copy and Report hand over: the words, the words of a card that is
|
||||
/// open here, an event's details. Empty for a sealed card.
|
||||
func plainText(now: Date = Date()) -> String {
|
||||
if let card { return isSealedCard(now: now) ? "" : card.plainText }
|
||||
if let event = event?.event {
|
||||
return [event.shownTitle, event.when(), event.shownPlace, event.shownNote]
|
||||
.compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: "\n")
|
||||
}
|
||||
// A game is its name and its moves, in the notation both apps use.
|
||||
if let game {
|
||||
return ([GameWords.summary(game.type)] + (game.log.isEmpty ? [] : [game.log.joined(separator: " ")]))
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
/// Whether Forward is offered: a card not sealed here, an event not
|
||||
/// cancelled, or words and attachments as before. Never a sticker, and
|
||||
/// never a game (§6.33).
|
||||
func canForward(now: Date = Date()) -> Bool {
|
||||
guard sticker == nil, game == nil else { return false }
|
||||
if card != nil { return !isSealedCard(now: now) }
|
||||
if let event { return !event.event.cancel }
|
||||
return !body.isEmpty || attachment != nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,7 +243,7 @@ final class MessengerAPI {
|
||||
|
||||
// MARK: - Display name
|
||||
|
||||
/// Section 8.12, stage B1: whether people can find this account by its
|
||||
/// Section 8.14, stage B1: whether people can find this account by its
|
||||
/// username. Nil from a relay that does not say.
|
||||
func discoverable() async throws -> Bool? {
|
||||
let url = client.restURL.appendingPathComponent("flamenet/v1/messenger/me")
|
||||
|
||||