diff --git a/deploy/README.md b/deploy/README.md index bcbead8..d6d2ca3 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -52,4 +52,15 @@ longer replays becomes unreachable after restart, so this is not optional. `setup-droplet.sh` does NOT install it — on a fresh droplet, copy the script, configure rclone (the script refuses to run while the config still holds its CHANGE_ME placeholder), and add the cron entry by hand. +- A player whose browser lost their seat (Safari's seven days, an app's own + browser, cleared data) writes to the keeper. On the droplet, + `wizwar-reseat.sh ROOM` lists who sits at the table and when each last + moved or spoke; `wizwar-reseat.sh ROOM NAME` gives that wizard's seat a new + token and prints a link carrying it in the fragment. The old token opens + nothing after. The script answers through the game server's keeper door, + a listener of its own on 127.0.0.1:9787 that Caddy never reaches, which + opens only to the key in /etc/wizwar/keeper.key (root and the wizwar user + alone can read it, so the other services on the host cannot use the door). + The first deploy makes the key, and every deploy installs the script at + /usr/local/bin. - One-off backup: `scp -r root@:/var/lib/wizwar/rooms ./rooms-backup` diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 7ce7db2..7a1eb36 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -25,6 +25,14 @@ ssh "root@$HOST" ' install -m 755 /opt/wizwar/deploy/wizwar-backup.sh /usr/local/bin/wizwar-backup.sh install -m 755 /opt/wizwar/deploy/wizwar-rollup.sh /usr/local/bin/wizwar-rollup.sh install -m 755 /opt/wizwar/deploy/wizwar-visitors.sh /usr/local/bin/wizwar-visitors.sh + install -m 755 /opt/wizwar/deploy/wizwar-reseat.sh /usr/local/bin/wizwar-reseat.sh + # The key to the keeper door: made once, readable by root (the reseat + # script) and the wizwar user (the server) alone. + if [ ! -s /etc/wizwar/keeper.key ]; then + install -d -m 750 -o root -g wizwar /etc/wizwar + (umask 037; openssl rand -hex 32 > /etc/wizwar/keeper.key) + chown root:wizwar /etc/wizwar/keeper.key + fi # On a host the hall shares, its hall-nightly.sh runs the rollup and backup # of every game and checks in with Sentry once for all; a game on a box of # its own schedules its own. diff --git a/deploy/wizwar-reseat.sh b/deploy/wizwar-reseat.sh new file mode 100755 index 0000000..2c73a4c --- /dev/null +++ b/deploy/wizwar-reseat.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# A way back for a player whose browser lost their seat: the seat gets a new +# token, the old one stops working, and the link printed carries the new one. +# Whether the one asking holds that seat is the keeper's call; send the link +# only to them. Runs ON the droplet, through the game server's keeper door, +# which listens on the loopback alone. +# wizwar-reseat.sh ROOM who sits at the table, and when each person last moved or spoke +# wizwar-reseat.sh ROOM NAME re-seat the wizard NAME and print the link +set -euo pipefail +ROOM="${1:?usage: wizwar-reseat.sh ROOM [NAME]}" +DOOR="http://127.0.0.1:${KEEPER_PORT:-9787}/keeper/rooms/${ROOM^^}" +KEY_FILE="${KEEPER_KEY_FILE:-/etc/wizwar/keeper.key}" +[ -s "$KEY_FILE" ] || { echo "No keeper's key at $KEY_FILE; a deploy makes it." >&2; exit 1; } +# The key goes as a header read from a file descriptor, never on a command line another user could see. +KEY="@/dev/fd/3" +exec 3< <(printf 'x-keeper-key: %s\n' "$(cat "$KEY_FILE")") +if [ $# -lt 2 ]; then + curl -s -H "$KEY" "$DOOR" | python3 -c ' +import json, sys +r = json.load(sys.stdin) +if "error" in r: sys.exit(r["error"]) +print("room %s, %s" % (r["roomId"], "finished" if r["finished"] else "in play" if r["started"] else "not begun")) +for s in r["seats"]: + when = "automaton" if s["bot"] else "never moved or spoke" if s["lastActed"] is None else "last acted " + s["lastActed"][:16].replace("T", " ") + " UTC" + print(" %-24s %-5s %s" % (s["name"], "host" if s["host"] else "", when)) +' +else + python3 -c 'import json, sys; print(json.dumps({"seat": sys.argv[1]}))' "$2" \ + | curl -s -X POST -H "$KEY" -H 'content-type: application/json' --data-binary @- "$DOOR/reseat" | python3 -c ' +import json, sys +r = json.load(sys.stdin) +if "error" in r: sys.exit(r["error"]) +print("%s has a new token; their old one opens nothing now. Send them this link:" % r["name"]) +print(r["link"]) +' +fi diff --git a/deploy/wizwar.service b/deploy/wizwar.service index cd88e51..08c3d08 100644 --- a/deploy/wizwar.service +++ b/deploy/wizwar.service @@ -9,6 +9,9 @@ WorkingDirectory=/opt/wizwar/packages/server Environment=PORT=8787 # Caddy terminates TLS; the plaintext port must not face the internet. Environment=HOST=127.0.0.1 +# The keeper's door (deploy/wizwar-reseat.sh) binds the loopback whatever HOST says. +Environment=KEEPER_PORT=9787 +Environment=KEEPER_KEY_FILE=/etc/wizwar/keeper.key Environment=WIZWAR_DATA_DIR=/var/lib/wizwar/rooms Environment=WIZWAR_STATIC_DIR=/opt/wizwar/packages/web/dist Environment=WIZWAR_KEEPER_TZ=America/New_York diff --git a/package-lock.json b/package-lock.json index f9bfd28..fbba991 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2759,7 +2759,8 @@ "@types/node": "^22.0.0", "@types/ws": "^8.5.0", "tsx": "^4.19.0", - "typescript": "^5.6.0" + "typescript": "^5.6.0", + "vitest": "^2.1.0" } }, "packages/web": { diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index 4d4719d..16962ee 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -1021,7 +1021,7 @@ describe("interception, escape, and hazard sense", () => { const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); expect(cmd).toMatchObject({ type: "cast", instanceId: "DC", target: { kind: "cell" } }); const r = applyCommand(state, "bot", cmd!); - expect(r.ok).toBe(true); + if (!r.ok) throw new Error(r.error); // The box gone, the very next thought is the grab. const next = automatonCommand(viewFor(r.state, "bot"), "hunter", "archmage"); expect(next).toMatchObject({ type: "pickUpTreasure" }); @@ -1050,7 +1050,7 @@ describe("interception, escape, and hazard sense", () => { cmd = automatonFallback(view, "archmage"); r = applyCommand(state, seat, cmd); } - expect(r.ok).toBe(true); + if (!r.ok) throw new Error(`${seat}: ${r.error}`); state = r.state; ended = seat === "bot" && cmd.type === "endTurn"; } diff --git a/packages/server/package.json b/packages/server/package.json index 6e68b3e..3d069c6 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "tsx watch src/index.ts", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run" }, "dependencies": { "@sentry/node": "^10.73.0", @@ -16,6 +17,7 @@ "@types/node": "^22.0.0", "@types/ws": "^8.5.0", "tsx": "^4.19.0", - "typescript": "^5.6.0" + "typescript": "^5.6.0", + "vitest": "^2.1.0" } } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 8098580..40be0c7 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -81,6 +81,7 @@ import { import { engagementStats, recordHotseat } from "./stats"; import { appendFeedback, readFeedback, readClips, clipAssetPath, CLIP_SLUG, feedbackImageDir } from "./store"; import { clipsIndexHtml, clipPageHtml } from "./clips"; +import { openKeeperDoor } from "./keeper"; import { SlidingLimit, clientAddress } from "./ratelimit"; import { getShare, loadShares, mintShare } from "./shares"; import { renderSharePng } from "./ogimage"; @@ -535,6 +536,19 @@ const httpServer = createServer((req, res) => { const wss = new WebSocketServer({ server: httpServer, maxPayload: 64 * 1024 }); httpServer.listen(port, host); +// The keeper's door listens on the loopback alone, on a port of its own, and +// opens only to the key in KEEPER_KEY_FILE (deploy/wizwar-reseat.sh). A +// socket still seated on the old token is closed: its reconnect offers that +// token, and it opens nothing now. +openKeeperDoor({ + port: Number(process.env.KEEPER_PORT ?? 9787), + publicUrl: PUBLIC_URL, + keyFile: process.env.KEEPER_KEY_FILE ?? "/etc/wizwar/keeper.key", + onReseat: (room, name) => { + for (const s of sessions) if (s.roomId === room.id && s.playerId === name) s.socket.close(); + }, +}); + interface Session { socket: WebSocket; playerId: PlayerId | null; diff --git a/packages/server/src/keeper.ts b/packages/server/src/keeper.ts new file mode 100644 index 0000000..f5295d8 --- /dev/null +++ b/packages/server/src/keeper.ts @@ -0,0 +1,83 @@ +// The keeper's door: a listener of its own on the loopback, apart from the +// port Caddy forwards to, so it answers only on the host itself, and only to +// the key in its key file, so another service on a shared host cannot use +// it. Its one caller is deploy/wizwar-reseat.sh, which sends the key: +// GET /keeper/rooms/:id who sits at the table, and when each person last moved or spoke +// POST /keeper/rooms/:id/reseat {seat} a new token for a player locked out, as a link to send them + +import { timingSafeEqual } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import type { PlayerId } from "@wizwar/engine"; +import { getRoom, reseat, seatsForKeeper, type Room } from "./rooms"; + +function send(res: ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body)); +} + +/** The key is read on each knock, so a key written after the server started is honoured. Absent, the door stays shut. */ +function keyGiven(req: IncomingMessage, keyFile: string): boolean { + let key: Buffer; + try { + key = Buffer.from(readFileSync(keyFile, "utf8").trim()); + } catch { + return false; + } + const given = Buffer.from(String(req.headers["x-keeper-key"] ?? "")); + return key.length > 0 && given.length === key.length && timingSafeEqual(given, key); +} + +async function readSeat(req: IncomingMessage): Promise { + let raw = ""; + for await (const chunk of req) { + raw += chunk; + if (raw.length > 4096) return ""; + } + try { + const seat = (JSON.parse(raw) as { seat?: unknown }).seat; + return typeof seat === "string" ? seat : ""; + } catch { + return ""; + } +} + +export interface KeeperDoor { + port: number; + publicUrl: string; + keyFile: string; + /** Lets the caller set adrift any socket still seated on the old token. */ + onReseat: (room: Room, name: PlayerId) => void; +} + +async function handle(req: IncomingMessage, res: ServerResponse, { publicUrl, keyFile, onReseat }: KeeperDoor): Promise { + const parts = (req.url ?? "/").split("?")[0]!.split("/").filter(Boolean); + // Nothing proxied belongs here: a forwarded request is turned away even if a proxy is someday pointed at this port. + if (req.headers["x-forwarded-for"] || !keyGiven(req, keyFile)) return send(res, 404, { error: "not here" }); + if (parts[0] !== "keeper" || parts[1] !== "rooms" || !parts[2]) return send(res, 404, { error: "not here" }); + const room = getRoom(parts[2]); + if (!room) return send(res, 404, { error: "no such room" }); + if (parts.length === 3 && req.method === "GET") { + return send(res, 200, { roomId: room.id, started: room.state !== null, finished: room.state?.phase === "finished", seats: seatsForKeeper(room) }); + } + if (parts.length === 4 && parts[3] === "reseat" && req.method === "POST") { + const name = await readSeat(req); + const result = reseat(room, name); + if ("error" in result) return send(res, 404, result); + onReseat(room, name); + // The token rides the fragment, which the browser keeps to itself: it reaches neither Caddy's log nor the server. + return send(res, 200, { name, link: `${publicUrl}/join/${room.id}#seat=${encodeURIComponent(name)}.${result.token}` }); + } + return send(res, 404, { error: "not here" }); +} + +/** Open the door on 127.0.0.1, whatever address the game itself listens on. */ +export function openKeeperDoor(door: KeeperDoor): Server { + const server = createServer((req, res) => { + handle(req, res, door).catch((e) => { + console.error("keeper door:", e); + if (!res.headersSent) send(res, 500, { error: "internal error" }); + }); + }); + server.listen(door.port, "127.0.0.1"); + return server; +} diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 8a33665..82650dd 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -331,6 +331,31 @@ export function kickSeat(room: Room, byId: PlayerId, name: PlayerId): string | n return null; } +/** The keeper gives a person's seat a new token, for a player whose browser + * lost the old one. Whether the one asking holds the seat is the keeper's + * judgment; this carries it out. The old token opens nothing after. */ +export function reseat(room: Room, name: PlayerId): { token: string } | { error: string } { + if (!room.tokens.has(name) || room.bots.has(name)) return { error: "no person sits in that seat" }; + const token = randomBytes(16).toString("hex"); + room.tokens.set(name, hashToken(token)); + appendLine(room.id, { kind: "reseat", name, tokenHash: hashToken(token), at: new Date().toISOString() }); + // A phrase minted with the old token would hand over a seat that no longer answers to it. + for (const [code, t] of transfers) if (t.roomId === room.id && t.name === name) transfers.delete(code); + return { token }; +} + +/** The table as the keeper sees it before re-seating: who sits where, and + * when each person last moved or spoke. */ +export function seatsForKeeper(room: Room): { name: PlayerId; host: boolean; bot: boolean; lastActed: string | null }[] { + const last = new Map(); + for (const c of room.log) last.set(c.playerId, c.at); + for (const c of room.chat) if ((last.get(c.player) ?? "") < c.at) last.set(c.player, c.at); + return room.players.map((name) => { + const bot = room.bots.has(name); + return { name, host: name === room.hostId, bot, lastActed: bot ? null : (last.get(name) ?? null) }; + }); +} + /** The host dissolves the room: a lobby that never dealt, or a finished * game done being remembered. The ledger is archived, never deleted. */ export function abandonRoom(room: Room, byId: PlayerId): string | null { @@ -835,6 +860,8 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null { room.tokens.delete(line.name); room.bots.delete(line.name); room.colorChoices.delete(line.name); + } else if (line.kind === "reseat") { + if (room.tokens.has(line.name)) room.tokens.set(line.name, line.tokenHash); } else if (line.kind === "join") { if (line.bot) { room.players.push(line.name); diff --git a/packages/server/src/store.ts b/packages/server/src/store.ts index 0b309d0..b49b35e 100644 --- a/packages/server/src/store.ts +++ b/packages/server/src/store.ts @@ -93,13 +93,22 @@ export interface KickLine { at: string; } +/** The keeper gave a person's seat a new token, for a player whose browser + * lost the old one; the old one opens nothing after. */ +export interface ReseatLine { + kind: "reseat"; + name: string; + tokenHash: string; + at: string; +} + export interface AbandonLine { kind: "abandon"; by: string; at: string; } -export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine | RematchLine | ChallengeLine | GalleryTalkLine; +export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | ReseatLine | AbandonLine | RematchLine | ChallengeLine | GalleryTalkLine; const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms"); diff --git a/packages/server/test/keeper.test.ts b/packages/server/test/keeper.test.ts new file mode 100644 index 0000000..16a3645 --- /dev/null +++ b/packages/server/test/keeper.test.ts @@ -0,0 +1,151 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { once } from "node:events"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +// The store reads its directory when first imported, so the rooms module is +// loaded only after the ledgers have somewhere of their own to go. +const scratch = mkdtempSync(join(tmpdir(), "wizwar-keeper-")); +process.env.WIZWAR_DATA_DIR = join(scratch, "rooms"); +const KEY_FILE = join(scratch, "keeper.key"); +const KEY = "a".repeat(64); + +let rooms: typeof import("../src/rooms"); +let keeper: typeof import("../src/keeper"); +beforeAll(async () => { + rooms = await import("../src/rooms"); + rooms.loadPersistedRooms(); + keeper = await import("../src/keeper"); +}); + +function table() { + const { room, token: ann } = rooms.createRoom("Ann"); + const joined = rooms.joinRoom(room, "Bo", null); + if ("error" in joined) throw new Error(joined.error); + const bot = rooms.addAutomaton(room); + if ("error" in bot) throw new Error(bot.error); + return { room, ann, bo: joined.token, bot: bot.name }; +} + +function token(result: { token: string } | { error: string }): string { + if ("error" in result) throw new Error(result.error); + return result.token; +} + +describe("the keeper's reseat", () => { + it("gives a person's seat a new token: the old one is refused and the new one opens the seat", () => { + const { room, bo } = table(); + const fresh = token(rooms.reseat(room, "Bo")); + expect(fresh).not.toBe(bo); + expect(rooms.joinRoom(room, "Bo", bo)).toEqual({ error: "that wizard name is taken in this room" }); + expect(rooms.joinRoom(room, "Bo", fresh)).toEqual({ token: fresh }); + expect(rooms.peekSummary(room.id, "Bo", bo)).toBe("badToken"); + }); + + it("re-seats the host the same way", () => { + const { room, ann } = table(); + const fresh = token(rooms.reseat(room, "Ann")); + expect("error" in rooms.joinRoom(room, "Ann", ann)).toBe(true); + expect(rooms.joinRoom(room, "Ann", fresh)).toEqual({ token: fresh }); + }); + + it("voids a transfer phrase minted with the old token", () => { + const { room, bo } = table(); + const minted = rooms.makeTransferCode(room, "Bo", bo); + if ("error" in minted) throw new Error(minted.error); + rooms.reseat(room, "Bo"); + expect("error" in rooms.claimTransferCode(minted.code)).toBe(true); + }); + + it("keeps the new token across a reload from the ledger", async () => { + const { room, ann, bo } = table(); + const fresh = token(rooms.reseat(room, "Bo")); + vi.resetModules(); + const reloaded: typeof import("../src/rooms") = await import("../src/rooms"); + const again = reloaded.getRoom(room.id); + expect(again).toBeDefined(); + expect(again).not.toBe(room); + expect(reloaded.joinRoom(again!, "Bo", bo)).toEqual({ error: "that wizard name is taken in this room" }); + expect(reloaded.joinRoom(again!, "Bo", fresh)).toEqual({ token: fresh }); + expect(reloaded.joinRoom(again!, "Ann", ann)).toEqual({ token: ann }); + }); + + it("re-seats only a person who sits at the table", () => { + const { room, bot } = table(); + expect(rooms.reseat(room, bot)).toEqual({ error: "no person sits in that seat" }); + expect(rooms.reseat(room, "Cy")).toEqual({ error: "no person sits in that seat" }); + }); + + it("tells the keeper who sits where, and when each person last moved or spoke", () => { + const { room, bot } = table(); + rooms.addChat(room, "Bo", "good luck"); + const seats = rooms.seatsForKeeper(room); + expect(seats.map((s) => [s.name, s.bot, s.host])).toEqual([["Ann", false, true], ["Bo", false, false], [bot, true, false]]); + expect(seats[0]!.lastActed).toBeNull(); + expect(seats[1]!.lastActed).toMatch(/^\d{4}-/); + expect(seats[2]!.lastActed).toBeNull(); + }); +}); + +describe("the keeper's door", () => { + let door: import("node:http").Server; + let base: string; + const knock = (path: string, init: RequestInit = {}) => + fetch(`${base}${path}`, { ...init, headers: { "x-keeper-key": KEY, ...(init.headers as Record) } }); + beforeAll(async () => { + writeFileSync(KEY_FILE, KEY + "\n"); + door = keeper.openKeeperDoor({ port: 0, publicUrl: "https://wizwar.example", keyFile: KEY_FILE, onReseat: () => {} }); + await once(door, "listening"); + const address = door.address() as AddressInfo; + base = `http://127.0.0.1:${address.port}`; + }); + afterAll(() => door.close()); + + it("listens on the loopback alone", () => { + expect((door.address() as AddressInfo).address).toBe("127.0.0.1"); + }); + + it("lists a table's seats and re-seats one as a link carrying the token in the fragment", async () => { + const { room, bo } = table(); + const listed = await (await knock(`/keeper/rooms/${room.id}`)).json(); + expect(listed.seats.map((s: { name: string }) => s.name)).toContain("Bo"); + const res = await knock(`/keeper/rooms/${room.id.toLowerCase()}/reseat`, { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ seat: "Bo" }), + }); + expect(res.status).toBe(200); + const { name, link } = await res.json(); + expect(name).toBe("Bo"); + const m = new RegExp(`^https://wizwar\\.example/join/${room.id}#seat=Bo\\.([0-9a-f]{32})$`).exec(link); + expect(m).not.toBeNull(); + expect("error" in rooms.joinRoom(room, "Bo", bo)).toBe(true); + expect(rooms.joinRoom(room, "Bo", m![1]!)).toEqual({ token: m![1] }); + }); + + it("turns away a forwarded request, an unknown room and a bot", async () => { + const { room, bot } = table(); + expect((await knock(`/keeper/rooms/${room.id}`, { headers: { "x-forwarded-for": "203.0.113.9" } })).status).toBe(404); + expect((await knock(`/keeper/rooms/${room.id}X`)).status).toBe(404); + const res = await knock(`/keeper/rooms/${room.id}/reseat`, { method: "POST", body: JSON.stringify({ seat: bot }) }); + expect(res.status).toBe(404); + expect((await res.json()).error).toBe("no person sits in that seat"); + }); + + it("opens only to the keeper's key, and stays shut while the key file is absent", async () => { + const { room, bo } = table(); + const reseatBo = (headers: Record) => + fetch(`${base}/keeper/rooms/${room.id}/reseat`, { method: "POST", headers, body: JSON.stringify({ seat: "Bo" }) }); + expect((await reseatBo({})).status).toBe(404); + expect((await reseatBo({ "x-keeper-key": "b".repeat(64) })).status).toBe(404); + expect((await reseatBo({ "x-keeper-key": KEY.slice(1) })).status).toBe(404); + rmSync(KEY_FILE); + try { + expect((await reseatBo({ "x-keeper-key": KEY })).status).toBe(404); + expect((await reseatBo({ "x-keeper-key": "" })).status).toBe(404); + } finally { + writeFileSync(KEY_FILE, KEY + "\n"); + } + expect(rooms.joinRoom(room, "Bo", bo)).toEqual({ token: bo }); + }); +}); diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index c7b37a2..e6b2735 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -4,5 +4,5 @@ "noEmit": true, "types": ["node"] }, - "include": ["src"] + "include": ["src", "test"] } diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 41c8752..e0cf7db 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1,6 +1,6 @@