Credibility pass: one teleport step, one bell, one step type, and the comments that argued are gone
Since 6e49fc9. Engine: the teleport BFS in the view and the engine's
wallIgnoringDistance share teleportStep; the counter trail is weighed
between steps instead of on six arms; the bush attack takes its
adjacency from SIDES like the wall does; a dead redirected clause, a
one-member set, an identity ternary, and a stray try/catch are gone;
two tests now assert what their names promise. Server: KEEPER and
MAX_SEATS live in rooms.ts, the three Sentry bells ring through one
helper, the unread reply field is dropped, actingSeat comes from the
engine, and the protocol header names every message. Web: ReplayStep
is one type, cardName one helper, the say box and the reference section
one snippet each, the keeper's name arrives from the server rather than
the source, and the lifesaver ruling that ruled nothing is out.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
c4a555f8df
commit
66b5c06f17
@@ -8,7 +8,7 @@
|
|||||||
// Make the old checkout with `git worktree add <dir> <commit>`. Each seed
|
// Make the old checkout with `git worktree add <dir> <commit>`. Each seed
|
||||||
// is played once per rotation of the table so seat order cancels out.
|
// is played once per rotation of the table so seat order cancels out.
|
||||||
|
|
||||||
import { applyCommand, automatonFallback, createGame, viewFor } from "../packages/engine/src/index.ts";
|
import { actingSeat, applyCommand, automatonFallback, createGame, viewFor } from "../packages/engine/src/index.ts";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
@@ -25,12 +25,6 @@ const brains = {
|
|||||||
old: old.automatonCommand,
|
old: old.automatonCommand,
|
||||||
};
|
};
|
||||||
|
|
||||||
function actingSeat(state) {
|
|
||||||
return state.wardPending?.ownerId ?? state.pushPending?.pusheeId ?? state.slowDeathPending?.playerId
|
|
||||||
?? state.pendingDiscard ?? state.chaosPending?.queue[0] ?? state.stack?.waitingOn
|
|
||||||
?? state.outOfTurnWindow?.playerId ?? state.players[state.turn.activeIndex].id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function playOut(seed, order) {
|
function playOut(seed, order) {
|
||||||
const ids = order.map((b, i) => `${b}${i + 1}`);
|
const ids = order.map((b, i) => `${b}${i + 1}`);
|
||||||
const brainOf = Object.fromEntries(ids.map((id, i) => [id, brains[order[i]]]));
|
const brainOf = Object.fromEntries(ids.map((id, i) => [id, brains[order[i]]]));
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ m = re.search(r'SENTRY_DSN=https://([0-9a-f]+)@([^/]+)/(\\d+)', svc)
|
|||||||
if not m: raise SystemExit(0)
|
if not m: raise SystemExit(0)
|
||||||
key, host, project = m.groups()
|
key, host, project = m.groups()
|
||||||
text = os.environ['TEXT']; rid = os.environ['REPORT_ID']; status = os.environ['STATUS']
|
text = os.environ['TEXT']; rid = os.environ['REPORT_ID']; status = os.environ['STATUS']
|
||||||
event = {'event_id': uuid.uuid4().hex, 'timestamp': datetime.datetime.now(datetime.UTC).isoformat(), 'level': 'error', 'platform': 'other',
|
now = datetime.datetime.now(datetime.UTC).isoformat()
|
||||||
'message': {'formatted': f'Desk replies on report {rid} ({status}): {text[:100]}'}, 'fingerprint': ['report-reply', rid, datetime.datetime.now(datetime.UTC).isoformat()],
|
event = {'event_id': uuid.uuid4().hex, 'timestamp': now, 'level': 'error', 'platform': 'other',
|
||||||
|
'message': {'formatted': f'Desk replies on report {rid} ({status}): {text[:100]}'}, 'fingerprint': ['report-reply', rid, now],
|
||||||
'tags': {'report': rid, 'status': status}, 'extra': {'text': text}}
|
'tags': {'report': rid, 'status': status}, 'extra': {'text': text}}
|
||||||
body = json.dumps({'event_id': event['event_id'], 'sent_at': event['timestamp']}) + '\\n' + json.dumps({'type': 'event'}) + '\\n' + json.dumps(event)
|
body = json.dumps({'event_id': event['event_id'], 'sent_at': event['timestamp']}) + '\\n' + json.dumps({'type': 'event'}) + '\\n' + json.dumps(event)
|
||||||
req = urllib.request.Request(f'https://{host}/api/{project}/envelope/', data=body.encode(), headers={'Content-Type': 'application/x-sentry-envelope', 'X-Sentry-Auth': f'Sentry sentry_version=7, sentry_client=wizwar-desk/1, sentry_key={key}'})
|
req = urllib.request.Request(f'https://{host}/api/{project}/envelope/', data=body.encode(), headers={'Content-Type': 'application/x-sentry-envelope', 'X-Sentry-Auth': f'Sentry sentry_version=7, sentry_client=wizwar-desk/1, sentry_key={key}'})
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ CHANNEL="${1:-#wizwar-notifications}"
|
|||||||
API="https://us.sentry.io/api/0/organizations/$ORG"
|
API="https://us.sentry.io/api/0/organizations/$ORG"
|
||||||
AUTH="Authorization: Bearer $SENTRY_TOKEN"
|
AUTH="Authorization: Bearer $SENTRY_TOKEN"
|
||||||
|
|
||||||
# What the API wants, learned the hard way: the Slack action needs the
|
# The Slack action takes the channel's ID as well as its name; the
|
||||||
# channel's ID as well as its name, the project's detector is found by
|
# project's error detector is looked up by project id; a workflow is bound
|
||||||
# project id, and the binding is a PUT of the workflow with detectorIds.
|
# to its detectors by PUT after creation.
|
||||||
SLACK_ID="${SENTRY_SLACK_INTEGRATION:-345334}"
|
SLACK_ID="${SENTRY_SLACK_INTEGRATION:-345334}"
|
||||||
CHANNEL_ID="${SLACK_CHANNEL_ID:?SLACK_CHANNEL_ID=C... is required (the Slack ID of the channel)}"
|
CHANNEL_ID="${SLACK_CHANNEL_ID:?SLACK_CHANNEL_ID=C... is required (the Slack ID of the channel)}"
|
||||||
PROJECT_ID="${SENTRY_PROJECT_ID:-4512011462049793}"
|
PROJECT_ID="${SENTRY_PROJECT_ID:-4512011462049793}"
|
||||||
@@ -68,6 +68,6 @@ if [ "$CODE" != "201" ]; then
|
|||||||
fi
|
fi
|
||||||
WORKFLOW_ID=$(printf '%s' "$BODY" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("id",""))')
|
WORKFLOW_ID=$(printf '%s' "$BODY" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("id",""))')
|
||||||
echo "created workflow $WORKFLOW_ID"
|
echo "created workflow $WORKFLOW_ID"
|
||||||
# A fresh workflow may come back without its detectors; bind them by PUT.
|
# Binding to the detector is done by PUT, the whole workflow resent.
|
||||||
curl -s -o /dev/null -w "bound to detector $DETECTOR_ID: %{http_code}\n" -X PUT "$API/workflows/$WORKFLOW_ID/" -H "$AUTH" -H "Content-Type: application/json" -d "$PAYLOAD"
|
curl -s -o /dev/null -w "bound to detector $DETECTOR_ID: %{http_code}\n" -X PUT "$API/workflows/$WORKFLOW_ID/" -H "$AUTH" -H "Content-Type: application/json" -d "$PAYLOAD"
|
||||||
echo "done: https://$ORG.sentry.io/monitors/alerts/$WORKFLOW_ID/"
|
echo "done: https://$ORG.sentry.io/monitors/alerts/$WORKFLOW_ID/"
|
||||||
|
|||||||
@@ -733,7 +733,7 @@ function deliveryWins(view: GameView, p: { id: PlayerId; home: Cell; carriedTrea
|
|||||||
/** Treasures a wizard has delivered: the scoreboard the table reads. */
|
/** Treasures a wizard has delivered: the scoreboard the table reads. */
|
||||||
function bankedBy(view: GameView, p: { id: PlayerId; home: Cell }): number {
|
function bankedBy(view: GameView, p: { id: PlayerId; home: Cell }): number {
|
||||||
return view.treasures.filter(
|
return view.treasures.filter(
|
||||||
(t) => t.owner !== p.id && t.position && !t.carriedBy && cellKey(t.position) === cellKey(p.home)).length;
|
(t) => t.owner !== p.id && t.position && cellKey(t.position) === cellKey(p.home)).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The enemy whose delivery in progress would end the game, if any. */
|
/** The enemy whose delivery in progress would end the game, if any. */
|
||||||
@@ -769,8 +769,8 @@ export function interceptCell(
|
|||||||
if (theirs == null || toHome == null || mine == null) continue;
|
if (theirs == null || toHome == null || mine == null) continue;
|
||||||
if (theirs + toHome !== road || mine > theirs) continue;
|
if (theirs + toHome !== road || mine > theirs) continue;
|
||||||
if (!best || mine < best.mine || (mine === best.mine && toHome < best.toHome)) {
|
if (!best || mine < best.mine || (mine === best.mine && toHome < best.toHome)) {
|
||||||
const [x, y] = k.split(",").map(Number);
|
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||||
best = { cell: { x: x!, y: y! }, mine, toHome };
|
best = { cell: { x, y }, mine, toHome };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return best?.cell ?? carrier.home;
|
return best?.cell ?? carrier.home;
|
||||||
@@ -1509,19 +1509,19 @@ export function automatonCommand(
|
|||||||
// would not have died anyway.
|
// would not have died anyway.
|
||||||
const weakest = [...visible].sort((a, b) => a.life - b.life)[0]!;
|
const weakest = [...visible].sort((a, b) => a.life - b.life)[0]!;
|
||||||
const finisher = bestAttack(view, weakest.id, tier);
|
const finisher = bestAttack(view, weakest.id, tier);
|
||||||
|
const thiefInSight = thief && visible.some((p) => p.id === thief.id) ? thief : null;
|
||||||
const runner = visible.find((p) => deliveryWins(view, p));
|
const runner = visible.find((p) => deliveryWins(view, p));
|
||||||
const myBank = bankedBy(view, self);
|
const myBank = bankedBy(view, self);
|
||||||
const leader = [...visible]
|
const leader = [...visible]
|
||||||
.filter((p) => bankedBy(view, p) > myBank)
|
.filter((p) => bankedBy(view, p) > myBank)
|
||||||
.sort((a, b) => bankedBy(view, b) - bankedBy(view, a) || a.life - b.life)[0];
|
.sort((a, b) => bankedBy(view, b) - bankedBy(view, a) || a.life - b.life)[0];
|
||||||
const target = thief && visible.some((p) => p.id === thief.id) ? thief
|
const target = thiefInSight
|
||||||
: finisher && finisher.damage >= weakest.life ? weakest
|
?? (finisher && finisher.damage >= weakest.life ? weakest : null)
|
||||||
: runner ?? leader ?? weakest;
|
?? runner ?? leader ?? weakest;
|
||||||
// DROP OBJECT shakes the treasure out of the hands that matter:
|
// DROP OBJECT shakes the treasure out of the hands that matter:
|
||||||
// the thief of my gold, or any carrier whose delivery wins.
|
// the thief of my gold, or any carrier whose delivery wins.
|
||||||
{
|
{
|
||||||
const mark = (thief && visible.some((p) => p.id === thief.id)) ? thief
|
const mark = thiefInSight ?? runner;
|
||||||
: visible.find((p) => deliveryWins(view, p));
|
|
||||||
const dob = mark ? inHand(view, "drop-object") : undefined;
|
const dob = mark ? inHand(view, "drop-object") : undefined;
|
||||||
if (mark && dob) {
|
if (mark && dob) {
|
||||||
return {
|
return {
|
||||||
@@ -1749,8 +1749,7 @@ export function automatonCommand(
|
|||||||
// BANK GUARD: enemy gold delivered to my home is my scoreboard, and
|
// BANK GUARD: enemy gold delivered to my home is my scoreboard, and
|
||||||
// anyone may snatch it off the floor. A raider nearer my bank than I
|
// anyone may snatch it off the floor. A raider nearer my bank than I
|
||||||
// am, with the bank stocked, outranks the next grab — run home.
|
// am, with the bank stocked, outranks the next grab — run home.
|
||||||
const bankedCount = view.treasures.filter(
|
const bankedCount = bankedBy(view, self);
|
||||||
(t) => t.owner !== you && t.position && cellKey(t.position) === cellKey(self.home)).length;
|
|
||||||
// The predicate reads only ENEMY positions — my own steps must not
|
// The predicate reads only ENEMY positions — my own steps must not
|
||||||
// flip it mid-march or the walker shuttles between goals.
|
// flip it mid-march or the walker shuttles between goals.
|
||||||
const bankThreatened = bankedCount > 0 && !self.carriedTreasureId &&
|
const bankThreatened = bankedCount > 0 && !self.carriedTreasureId &&
|
||||||
|
|||||||
+48
-33
@@ -613,9 +613,6 @@ function castSightEdge(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The attacks that burn: what the FAQ lets hurt a KILLER OOZE. */
|
|
||||||
const FIRE_ATTACKS = new Set(["fireball"]);
|
|
||||||
|
|
||||||
function inThornbush(state: GameState, p: PlayerState): boolean {
|
function inThornbush(state: GameState, p: PlayerState): boolean {
|
||||||
return state.squareContents[cellKey(p.position)]?.kind === "thornbush";
|
return state.squareContents[cellKey(p.position)]?.kind === "thornbush";
|
||||||
}
|
}
|
||||||
@@ -3826,6 +3823,20 @@ export function edgeReentry(board: AssembledBoard, cur: Cell, side: Side): Cell
|
|||||||
* `wrapEdges` (rev 22) the outer edge is no more to a teleporter than any
|
* `wrapEdges` (rev 22) the outer edge is no more to a teleporter than any
|
||||||
* wall: a line leaving the maze re-enters at the opposite edge, one space
|
* wall: a line leaving the maze re-enters at the opposite edge, one space
|
||||||
* on. Without it, only the lettered openings carry a teleporter across. */
|
* on. Without it, only the lettered openings carry a teleporter across. */
|
||||||
|
/**
|
||||||
|
* One teleporter's step from `cur` toward `side`: the neighbouring
|
||||||
|
* square, the far mouth of a warp (one step, like any doorway), or — with
|
||||||
|
* `wrapEdges` — the square the board's opposite edge lets back in. Null
|
||||||
|
* where the maze ends.
|
||||||
|
*/
|
||||||
|
export function teleportStep(board: AssembledBoard, cur: Cell, side: Side, wrapEdges: boolean): Cell | null {
|
||||||
|
const n = neighbor(cur, side);
|
||||||
|
if (board.cells[cellKey(n)]) return n;
|
||||||
|
const w = board.warps.find((w) => cellKey(w.from.cell) === cellKey(cur) && w.from.side === side);
|
||||||
|
if (w) return w.to.cell;
|
||||||
|
return wrapEdges ? edgeReentry(board, cur, side) : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell, wrapEdges = false): number {
|
export function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell, wrapEdges = false): number {
|
||||||
if (cellKey(from) === cellKey(to)) return 0;
|
if (cellKey(from) === cellKey(to)) return 0;
|
||||||
const seen = new Map<string, number>([[cellKey(from), 0]]);
|
const seen = new Map<string, number>([[cellKey(from), 0]]);
|
||||||
@@ -3835,20 +3846,8 @@ export function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell
|
|||||||
const d = seen.get(cellKey(cur))!;
|
const d = seen.get(cellKey(cur))!;
|
||||||
if (d >= 8) break; // teleport range is 4; stop early
|
if (d >= 8) break; // teleport range is 4; stop early
|
||||||
for (const side of SIDES) {
|
for (const side of SIDES) {
|
||||||
let n = neighbor(cur, side);
|
const n = teleportStep(board, cur, side, wrapEdges);
|
||||||
if (!board.cells[cellKey(n)]) {
|
if (!n) continue;
|
||||||
// The maze wraps for walkers; it wraps for teleporters too —
|
|
||||||
// a warp mouth is one step, same as any doorway.
|
|
||||||
const w = board.warps.find(
|
|
||||||
(w) => cellKey(w.from.cell) === cellKey(cur) && w.from.side === side,
|
|
||||||
);
|
|
||||||
if (w) n = w.to.cell;
|
|
||||||
else if (wrapEdges) {
|
|
||||||
const back = edgeReentry(board, cur, side);
|
|
||||||
if (!back) continue;
|
|
||||||
n = back;
|
|
||||||
} else continue;
|
|
||||||
}
|
|
||||||
if (seen.has(cellKey(n))) continue;
|
if (seen.has(cellKey(n))) continue;
|
||||||
seen.set(cellKey(n), d + 1);
|
seen.set(cellKey(n), d + 1);
|
||||||
if (cellKey(n) === cellKey(to)) return d + 1;
|
if (cellKey(n) === cellKey(to)) return d + 1;
|
||||||
@@ -5538,26 +5537,27 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
const cell = cmd.target.cell;
|
const cell = cmd.target.cell;
|
||||||
const content = state.squareContents[cellKey(cell)];
|
const content = state.squareContents[cellKey(cell)];
|
||||||
if (content && (content.kind === "thornbush" || content.kind === "rosebush" || content.kind === "ooze")) {
|
if (content && (content.kind === "thornbush" || content.kind === "rosebush" || content.kind === "ooze")) {
|
||||||
const what = content.kind === "ooze" ? "ooze" : content.kind;
|
const what = content.kind;
|
||||||
if (effect.sameSquare) {
|
if (effect.sameSquare) {
|
||||||
const dx = cell.x - origin.x, dy = cell.y - origin.y;
|
const side = SIDES.find((s) => cellKey(neighbor(origin, s)) === cellKey(cell));
|
||||||
const side: Side | null = dx === 1 && dy === 0 ? "E" : dx === -1 && dy === 0 ? "W" : dy === 1 && dx === 0 ? "S" : dy === -1 && dx === 0 ? "N" : null;
|
|
||||||
if (!side || boardView(state).edges[edgeKey(origin, side)] === "wall") {
|
if (!side || boardView(state).edges[edgeKey(origin, side)] === "wall") {
|
||||||
return err(`you must stand beside the ${what} to use that`);
|
return err(`you must stand beside the ${what} to use that`);
|
||||||
}
|
}
|
||||||
} else if (effect.requiresLos && !castSight(state, caster, cmd, cell)) {
|
} else if (effect.requiresLos && !castSight(state, caster, cmd, cell)) {
|
||||||
return err(`no line of sight to the ${what}`);
|
return err(`no line of sight to the ${what}`);
|
||||||
}
|
}
|
||||||
if (content.kind === "ooze" && !FIRE_ATTACKS.has(inHand.cardId)) {
|
// The FAQ lets only fire hurt a KILLER OOZE, and FIREBALL is the
|
||||||
|
// deck's fire.
|
||||||
|
if (content.kind === "ooze" && inHand.cardId !== "fireball") {
|
||||||
return err("only fire hurts the ooze");
|
return err("only fire hurts the ooze");
|
||||||
}
|
}
|
||||||
|
const dmg = effect.baseDamage(mods.magnitude.numberValue, cmd.params ?? null) * (2 ** mods.amplifies.length);
|
||||||
|
if (dmg <= 0) return err(`that spell would not singe the ${what}`);
|
||||||
const wandEvents: GameEvent[] = [];
|
const wandEvents: GameEvent[] = [];
|
||||||
{
|
{
|
||||||
const werr = spendWandCharge(state, caster, wandEvents);
|
const werr = spendWandCharge(state, caster, wandEvents);
|
||||||
if (werr) return err(werr);
|
if (werr) return err(werr);
|
||||||
}
|
}
|
||||||
const dmg = effect.baseDamage(mods.magnitude.numberValue, cmd.params ?? null) * (2 ** mods.amplifies.length);
|
|
||||||
if (dmg <= 0) return err(`that spell would not singe the ${what}`);
|
|
||||||
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
|
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
|
||||||
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
|
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
|
||||||
state.turn.attackUsed = true;
|
state.turn.attackUsed = true;
|
||||||
@@ -6402,24 +6402,25 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
|||||||
stack.counters = stack.counters.filter((c) => c.nullified || c.card.cardId !== "full-shield");
|
stack.counters = stack.counters.filter((c) => c.nullified || c.card.cardId !== "full-shield");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Each counter's step is weighed once the next begins, or the loop
|
||||||
|
// ends: what it left of the blow.
|
||||||
|
let mark: CounterStep | null = null;
|
||||||
|
const weigh = () => { if (mark) { mark.damage = pipe.damage; mark.reflected = pipe.reflectedDamage; mark.duration = pipe.duration; } };
|
||||||
for (const counter of stack.counters) {
|
for (const counter of stack.counters) {
|
||||||
trail.push({ cardId: counter.card.cardId, player: counter.player, nullified: counter.nullified, damage: pipe.damage, reflected: pipe.reflectedDamage, duration: pipe.duration });
|
mark = { cardId: counter.card.cardId, player: counter.player, nullified: counter.nullified, damage: pipe.damage, reflected: pipe.reflectedDamage, duration: pipe.duration };
|
||||||
const mark = trail[trail.length - 1]!;
|
trail.push(mark);
|
||||||
const weigh = () => { mark.damage = pipe.damage; mark.reflected = pipe.reflectedDamage; mark.duration = pipe.duration; };
|
|
||||||
if (counter.nullified) continue;
|
if (counter.nullified) continue;
|
||||||
if (isNumberCard(counter.card.cardId)) {
|
if (isNumberCard(counter.card.cardId)) {
|
||||||
// SHIELDSTONE number counter: reduce point AND duration effects.
|
// SHIELDSTONE number counter: reduce point AND duration effects.
|
||||||
const v = numberValue(counter.card.cardId);
|
const v = numberValue(counter.card.cardId);
|
||||||
pipe.damage = Math.max(0, pipe.damage - v);
|
pipe.damage = Math.max(0, pipe.damage - v);
|
||||||
pipe.duration = Math.max(0, pipe.duration - v);
|
pipe.duration = Math.max(0, pipe.duration - v);
|
||||||
weigh();
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (counter.card.cardId === "wall-of-fire" || counter.card.cardId === "waterwall") {
|
if (counter.card.cardId === "wall-of-fire" || counter.card.cardId === "waterwall") {
|
||||||
// Fire meets water, whichever was thrown first: entirely stopped.
|
// Fire meets water, whichever was thrown first: entirely stopped.
|
||||||
pipe.damage = 0;
|
pipe.damage = 0;
|
||||||
pipe.fullyStopped = true;
|
pipe.fullyStopped = true;
|
||||||
weigh();
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (counter.card.cardId === "invisible" || counter.card.cardId === "empathy") {
|
if (counter.card.cardId === "invisible" || counter.card.cardId === "empathy") {
|
||||||
@@ -6429,7 +6430,6 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
|||||||
pipe.damage = 0;
|
pipe.damage = 0;
|
||||||
pipe.duration = 0;
|
pipe.duration = 0;
|
||||||
pipe.fullyStopped = true;
|
pipe.fullyStopped = true;
|
||||||
weigh();
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (stack.creatureId &&
|
if (stack.creatureId &&
|
||||||
@@ -6442,13 +6442,13 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
|||||||
} else {
|
} else {
|
||||||
pipe.redirected = true; // the whole blow turns back (damage rides pipe.damage)
|
pipe.redirected = true; // the whole blow turns back (damage rides pipe.damage)
|
||||||
}
|
}
|
||||||
weigh();
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const ce = CARD_EFFECTS[counter.card.cardId];
|
const ce = CARD_EFFECTS[counter.card.cardId];
|
||||||
if (ce && ce.kind === "counter") ce.apply(pipe);
|
if (ce && ce.kind === "counter") ce.apply(pipe);
|
||||||
weigh();
|
|
||||||
}
|
}
|
||||||
|
weigh();
|
||||||
|
|
||||||
|
|
||||||
// A surviving teleport counter whisks the defender away before anything lands.
|
// A surviving teleport counter whisks the defender away before anything lands.
|
||||||
const escape = stack.counters.find((c) => !c.nullified && c.card.cardId === "teleport" && c.cell);
|
const escape = stack.counters.find((c) => !c.nullified && c.card.cardId === "teleport" && c.cell);
|
||||||
@@ -6617,7 +6617,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
|||||||
// REFLECTION's split to halve, so the curse is laid on the caster as
|
// REFLECTION's split to halve, so the curse is laid on the caster as
|
||||||
// well, as if the defender had cast it back.
|
// well, as if the defender had cast it back.
|
||||||
const curseShared = effect?.permanentCurse === true && (state.config.deckRev ?? 1) >= 24 &&
|
const curseShared = effect?.permanentCurse === true && (state.config.deckRev ?? 1) >= 24 &&
|
||||||
!pipe.redirected && !pipe.fullyStopped && !stack.reflectedBase && !stack.trapped && attacker.alive &&
|
!pipe.fullyStopped && !stack.reflectedBase && !stack.trapped && attacker.alive &&
|
||||||
stack.counters.some((c) => !c.nullified && c.card.cardId === "reflection");
|
stack.counters.some((c) => !c.nullified && c.card.cardId === "reflection");
|
||||||
if (effect?.onResolved && curseShared) {
|
if (effect?.onResolved && curseShared) {
|
||||||
effect.onResolved({
|
effect.onResolved({
|
||||||
@@ -6628,7 +6628,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
|||||||
stack,
|
stack,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (effect?.onResolved && !pipe.redirected) {
|
if (effect?.onResolved) {
|
||||||
effect.onResolved({
|
effect.onResolved({
|
||||||
state,
|
state,
|
||||||
events,
|
events,
|
||||||
@@ -7384,3 +7384,18 @@ function doEndTurn(prev: GameState, draw: number): CommandResult {
|
|||||||
impCheck(state, events, state.players[next]!.id);
|
impCheck(state, events, state.players[next]!.id);
|
||||||
return { ok: true, state, events };
|
return { ok: true, state, events };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whose input the maze wants right now: a hanging window first, then
|
||||||
|
* the stack, then the discard, then the turn's holder. */
|
||||||
|
export function actingSeat(state: GameState): PlayerId {
|
||||||
|
return (
|
||||||
|
state.wardPending?.ownerId ??
|
||||||
|
state.pushPending?.pusheeId ??
|
||||||
|
state.slowDeathPending?.playerId ??
|
||||||
|
state.stack?.waitingOn ??
|
||||||
|
state.pendingDiscard ??
|
||||||
|
state.chaosPending?.queue[0] ??
|
||||||
|
state.outOfTurnWindow?.playerId ??
|
||||||
|
state.players[state.turn.activeIndex]!.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+13
-25
@@ -20,6 +20,7 @@ import {
|
|||||||
type TreasureState,
|
type TreasureState,
|
||||||
type TurnState,
|
type TurnState,
|
||||||
dreadDistance,
|
dreadDistance,
|
||||||
|
teleportStep,
|
||||||
} from "./game";
|
} from "./game";
|
||||||
|
|
||||||
export interface PlayerPublicView {
|
export interface PlayerPublicView {
|
||||||
@@ -367,13 +368,6 @@ export function bentSightFor(view: GameView, from: Cell, to: Cell): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The sight line behind the attack currently on the stack — the board's
|
|
||||||
* answer to "how can he even see me?". Null when nothing should draw:
|
|
||||||
* no stack, a creature's or physical attack, a non-LOS card, attacker and
|
|
||||||
* defender sharing a square, or no sight under this viewer's knowledge
|
|
||||||
* (a believed illusion wall can honestly hide the line).
|
|
||||||
*/
|
|
||||||
/** A sight line as the board draws it: the legs, the corner bend, and the
|
/** A sight line as the board draws it: the legs, the corner bend, and the
|
||||||
* one wall a VISIONSTONE dissolved. */
|
* one wall a VISIONSTONE dissolved. */
|
||||||
export interface SightLine {
|
export interface SightLine {
|
||||||
@@ -429,7 +423,13 @@ export function sightTraceBetween(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The sight line of the attack on the stack, for the board to draw. */
|
/**
|
||||||
|
* The sight line behind the attack currently on the stack — the board's
|
||||||
|
* answer to "how can he even see me?". Null when nothing should draw:
|
||||||
|
* no stack, a creature's or physical attack, a non-LOS card, attacker and
|
||||||
|
* defender sharing a square, or no sight under this viewer's knowledge
|
||||||
|
* (a believed illusion wall can honestly hide the line).
|
||||||
|
*/
|
||||||
export function stackSightTrace(view: GameView): SightLine | null {
|
export function stackSightTrace(view: GameView): SightLine | null {
|
||||||
const stack = view.stack;
|
const stack = view.stack;
|
||||||
if (!stack || stack.creatureId) return null;
|
if (!stack || stack.creatureId) return null;
|
||||||
@@ -453,9 +453,7 @@ export function castSightTrace(
|
|||||||
events: readonly GameEvent[],
|
events: readonly GameEvent[],
|
||||||
afterTheFact = false,
|
afterTheFact = false,
|
||||||
): SightLine | null {
|
): SightLine | null {
|
||||||
let def;
|
if (cardDef(cast.cardId).los !== true) return null;
|
||||||
try { def = cardDef(cast.cardId); } catch { return null; }
|
|
||||||
if (def.los !== true) return null;
|
|
||||||
const to = cast.targetCell ?? view.players.find((p) => p.id === cast.target)?.position ?? null;
|
const to = cast.targetCell ?? view.players.find((p) => p.id === cast.target)?.position ?? null;
|
||||||
if (!to) return null;
|
if (!to) return null;
|
||||||
const caster = view.players.find((p) => p.id === cast.caster);
|
const caster = view.players.find((p) => p.id === cast.caster);
|
||||||
@@ -517,9 +515,8 @@ export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = fa
|
|||||||
|
|
||||||
if (cardId === "teleport") {
|
if (cardId === "teleport") {
|
||||||
// Up to four spaces, walls and objects ignored; not into solid stone.
|
// Up to four spaces, walls and objects ignored; not into solid stone.
|
||||||
// BFS over existing cells, matching the engine's wallIgnoringDistance —
|
// The maze wraps for teleporters as it does for walkers, and from
|
||||||
// the maze wraps for teleporters as it does for walkers, a warp mouth
|
// rev 22 the outer edge is no more than a wall to them.
|
||||||
// being one step like any doorway.
|
|
||||||
const out = new Set<string>();
|
const out = new Set<string>();
|
||||||
const dist = new Map<string, number>([[key(me.position.x, me.position.y), 0]]);
|
const dist = new Map<string, number>([[key(me.position.x, me.position.y), 0]]);
|
||||||
const queue = [me.position];
|
const queue = [me.position];
|
||||||
@@ -528,17 +525,8 @@ export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = fa
|
|||||||
const d = dist.get(key(cur.x, cur.y))!;
|
const d = dist.get(key(cur.x, cur.y))!;
|
||||||
if (d >= 4) continue;
|
if (d >= 4) continue;
|
||||||
for (const side of SIDES) {
|
for (const side of SIDES) {
|
||||||
let n = neighbor(cur, side);
|
const n = teleportStep(view.board, cur, side, view.deckRev >= 22);
|
||||||
if (!view.board.cells[key(n.x, n.y)]) {
|
if (!n) continue;
|
||||||
const w = view.board.warps.find((w) => w.from.cell.x === cur.x && w.from.cell.y === cur.y && w.from.side === side);
|
|
||||||
if (w) n = w.to.cell;
|
|
||||||
else if (view.deckRev >= 22) {
|
|
||||||
// Rev 22: the outer edge is no more than a wall to a teleporter.
|
|
||||||
const back = edgeReentry(view.board, cur, side);
|
|
||||||
if (!back) continue;
|
|
||||||
n = back;
|
|
||||||
} else continue;
|
|
||||||
}
|
|
||||||
const nk = key(n.x, n.y);
|
const nk = key(n.x, n.y);
|
||||||
if (dist.has(nk)) continue;
|
if (dist.has(nk)) continue;
|
||||||
dist.set(nk, d + 1);
|
dist.set(nk, d + 1);
|
||||||
|
|||||||
@@ -1262,14 +1262,14 @@ describe("the common enemy and the interception", () => {
|
|||||||
|
|
||||||
it("leaves a carrier alone whose delivery would not win", () => {
|
it("leaves a carrier alone whose delivery would not win", () => {
|
||||||
const state = tableOf(["bot", "foe"], 5);
|
const state = tableOf(["bot", "foe"], 5);
|
||||||
const bot = state.players.find((p) => p.id === "bot")!;
|
|
||||||
const foe = state.players.find((p) => p.id === "foe")!;
|
const foe = state.players.find((p) => p.id === "foe")!;
|
||||||
|
const forGold = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||||
|
const forFoe = automatonCommand(viewFor(state, "bot"), "berserker", "archmage");
|
||||||
|
expect(forGold).not.toEqual(forFoe);
|
||||||
const t = state.treasures.find((t) => t.owner === "bot" && t.position && !t.carriedBy)!;
|
const t = state.treasures.find((t) => t.owner === "bot" && t.position && !t.carriedBy)!;
|
||||||
t.position = null; t.carriedBy = "foe"; foe.carriedTreasureId = t.id;
|
t.position = null; t.carriedBy = "foe"; foe.carriedTreasureId = t.id;
|
||||||
// Nothing banked: the foe is merely a thief of my gold — the chase
|
// Nothing banked: the foe is merely a thief of my gold, and with no
|
||||||
// rule, not the interception, decides; with no attack in hand the
|
// attack in hand there is no chase — the hunter's march is the gold's.
|
||||||
// hunter goes for gold as before.
|
expect(automatonCommand(viewFor(state, "bot"), "hunter", "archmage")).toEqual(forGold);
|
||||||
const plain = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
|
||||||
expect(plain?.type).toBe("move");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -597,7 +597,7 @@ describe("POWER DRAIN drains the number played (rev 23)", () => {
|
|||||||
expect(state.players.find((p) => p.id === me.id)!.life).toBe(18);
|
expect(state.players.find((p) => p.id === me.id)!.life).toBe(18);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("older games gave only what the opponent lost, and nothing from a wall", () => {
|
it("older games took nothing from a drained wall", () => {
|
||||||
let state = toRound2(createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 22 }).state);
|
let state = toRound2(createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 22 }).state);
|
||||||
const me = activePlayer(state);
|
const me = activePlayer(state);
|
||||||
const board = boardView(state);
|
const board = boardView(state);
|
||||||
|
|||||||
@@ -755,12 +755,13 @@ describe("attacks aimed at a bush or the ooze", () => {
|
|||||||
expect(near.ok).toBe(true);
|
expect(near.ok).toBe(true);
|
||||||
if (near.ok) expect(near.state.squareContents[cellKey(cell)]).toEqual(expect.objectContaining({ kind: "thornbush", damage: 3 }));
|
if (near.ok) expect(near.state.squareContents[cellKey(cell)]).toEqual(expect.objectContaining({ kind: "thornbush", damage: 3 }));
|
||||||
|
|
||||||
const far = sightedCellsFor(viewFor(state, me.id));
|
// Adjacency is checked before sight, so any square two or more away
|
||||||
const farKey = [...far].find((k) => {
|
// will do — seen or not.
|
||||||
|
const farKey = Object.keys(state.board.cells).find((k) => {
|
||||||
const [x, y] = k.split(",").map(Number);
|
const [x, y] = k.split(",").map(Number);
|
||||||
return Math.abs(x! - me.position.x) + Math.abs(y! - me.position.y) >= 2 && !state.squareContents[k];
|
return Math.abs(x! - me.position.x) + Math.abs(y! - me.position.y) >= 2 && !state.squareContents[k];
|
||||||
});
|
});
|
||||||
if (!farKey) return;
|
if (!farKey) throw new Error("no square two or more away on this board");
|
||||||
const [fx, fy] = farKey.split(",").map(Number);
|
const [fx, fy] = farKey.split(",").map(Number);
|
||||||
state.squareContents[farKey] = { kind: "thornbush", damage: 0, createdBy: "bob" };
|
state.squareContents[farKey] = { kind: "thornbush", damage: 0, createdBy: "bob" };
|
||||||
const refused = applyCommand(state, me.id, {
|
const refused = applyCommand(state, me.id, {
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
// {type:"myFeedback", seats} your reports + the wizards' replies
|
// {type:"myFeedback", seats} your reports + the wizards' replies
|
||||||
// {type:"rollDie"} the tabletop D4, published as talk
|
// {type:"rollDie"} the tabletop D4, published as talk
|
||||||
// {type:"addBot", style?, tier?} host seats an automaton
|
// {type:"addBot", style?, tier?} host seats an automaton
|
||||||
|
// {type:"challengeKeeper"} call the keeper of the site to a seat
|
||||||
|
// {type:"rematch"} a finished table asks for another
|
||||||
|
// {type:"feedbackAnswer", seat, reportId, text} a player's word under the desk's reply
|
||||||
// {type:"watch", roomId} join the Peanut Gallery: nameless, read-only
|
// {type:"watch", roomId} join the Peanut Gallery: nameless, read-only
|
||||||
// {type:"leave"} detach this socket from table or gallery
|
// {type:"leave"} detach this socket from table or gallery
|
||||||
// {type:"myGames", seats} summaries for held seats
|
// {type:"myGames", seats} summaries for held seats
|
||||||
@@ -27,18 +30,22 @@
|
|||||||
// {type:"chat", player, text, at} one line of table talk
|
// {type:"chat", player, text, at} one line of table talk
|
||||||
// {type:"watching", roomId} you are seated in the gallery
|
// {type:"watching", roomId} you are seated in the gallery
|
||||||
// {type:"audience", count} how many watch from the gallery
|
// {type:"audience", count} how many watch from the gallery
|
||||||
|
// {type:"rematch", roomId, to, by} the table hears where the rematch went
|
||||||
|
// {type:"rematched", roomId} the caller's own seat at the new table
|
||||||
// {type:"transferCode"|"transferClaimed"|"catchUp"|"games"|"stats"|"feedbackReceived"|"feedbackList"}
|
// {type:"transferCode"|"transferClaimed"|"catchUp"|"games"|"stats"|"feedbackReceived"|"feedbackList"}
|
||||||
// {type:"error", message}
|
// {type:"error", message}
|
||||||
|
|
||||||
import * as Sentry from "@sentry/node";
|
import * as Sentry from "@sentry/node";
|
||||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { readFileSync, existsSync, realpathSync, statSync, createReadStream } from "node:fs";
|
import { readFileSync, existsSync, realpathSync, statSync, createReadStream, mkdirSync, writeFileSync } from "node:fs";
|
||||||
import { extname, join, normalize, sep } from "node:path";
|
import { extname, join, normalize, sep } from "node:path";
|
||||||
import { WebSocketServer, WebSocket } from "ws";
|
import { WebSocketServer, WebSocket } from "ws";
|
||||||
import { cardDef } from "@wizwar/engine";
|
import { cardDef } from "@wizwar/engine";
|
||||||
import type { Command, PlayerId } from "@wizwar/engine";
|
import type { Command, PlayerId } from "@wizwar/engine";
|
||||||
import { callKeeper, callRematch,
|
import {
|
||||||
|
callKeeper,
|
||||||
|
callRematch,
|
||||||
catchUpSteps,
|
catchUpSteps,
|
||||||
momentSteps,
|
momentSteps,
|
||||||
claimTransferCode,
|
claimTransferCode,
|
||||||
@@ -64,10 +71,11 @@ import { callKeeper, callRematch,
|
|||||||
type Room,
|
type Room,
|
||||||
kickSeat,
|
kickSeat,
|
||||||
abandonRoom,
|
abandonRoom,
|
||||||
|
KEEPER,
|
||||||
|
MAX_SEATS,
|
||||||
} from "./rooms";
|
} from "./rooms";
|
||||||
import { engagementStats, recordHotseat } from "./stats";
|
import { engagementStats, recordHotseat } from "./stats";
|
||||||
import { appendFeedback, readFeedback, readClips, clipAssetPath, CLIP_SLUG, feedbackImageDir } from "./store";
|
import { appendFeedback, readFeedback, readClips, clipAssetPath, CLIP_SLUG, feedbackImageDir } from "./store";
|
||||||
import { mkdirSync, writeFileSync } from "node:fs";
|
|
||||||
import { clipsIndexHtml, clipPageHtml } from "./clips";
|
import { clipsIndexHtml, clipPageHtml } from "./clips";
|
||||||
import { SlidingLimit, clientAddress } from "./ratelimit";
|
import { SlidingLimit, clientAddress } from "./ratelimit";
|
||||||
import { getShare, loadShares, mintShare } from "./shares";
|
import { getShare, loadShares, mintShare } from "./shares";
|
||||||
@@ -80,6 +88,14 @@ if (process.env.SENTRY_DSN) {
|
|||||||
Sentry.init({ dsn: process.env.SENTRY_DSN, environment: "production", tracesSampleRate: 0 });
|
Sentry.init({ dsn: process.env.SENTRY_DSN, environment: "production", tracesSampleRate: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The bell the keeper's phone answers: a Sentry issue at error level,
|
||||||
|
* because the alert rule rings for high-priority issues and a warning is
|
||||||
|
* filed as medium — a bell nobody hears. The SDK drops it when no DSN is
|
||||||
|
* set. */
|
||||||
|
function ringBell(title: string, detail: { fingerprint: string[]; tags: Record<string, string>; extra: Record<string, unknown> }): void {
|
||||||
|
Sentry.captureMessage(title, { level: "error", ...detail });
|
||||||
|
}
|
||||||
|
|
||||||
// --- Abuse limits: this is a public server on a small box. -----------------
|
// --- Abuse limits: this is a public server on a small box. -----------------
|
||||||
const MAX_SOCKETS = 300; // concurrent connections
|
const MAX_SOCKETS = 300; // concurrent connections
|
||||||
const MAX_ROOMS = 5000; // total rooms on the server
|
const MAX_ROOMS = 5000; // total rooms on the server
|
||||||
@@ -131,8 +147,6 @@ function receiveFeedbackImage(req: IncomingMessage, res: ServerResponse, reportI
|
|||||||
|
|
||||||
/** Calls to the keeper: a real person's phone rings for each. */
|
/** Calls to the keeper: a real person's phone rings for each. */
|
||||||
const challengesPerAddress = new SlidingLimit(3, 60 * 60 * 1000);
|
const challengesPerAddress = new SlidingLimit(3, 60 * 60 * 1000);
|
||||||
/** The keeper of this table: the wizard a lobby may challenge. */
|
|
||||||
const KEEPER = process.env.WIZWAR_KEEPER ?? "Kestrel";
|
|
||||||
const PUBLIC_URL = (process.env.WIZWAR_PUBLIC_URL ?? "https://wizwar.kestrelsnest.social").replace(/\/$/, "");
|
const PUBLIC_URL = (process.env.WIZWAR_PUBLIC_URL ?? "https://wizwar.kestrelsnest.social").replace(/\/$/, "");
|
||||||
const reportsPerAddress = new SlidingLimit(6, 60 * 60 * 1000);
|
const reportsPerAddress = new SlidingLimit(6, 60 * 60 * 1000);
|
||||||
const MAX_COMMAND_BYTES = 16384; // serialized game command
|
const MAX_COMMAND_BYTES = 16384; // serialized game command
|
||||||
@@ -769,8 +783,7 @@ wss.on("connection", (socket, req) => {
|
|||||||
if (!room) return send(socket, { type: "error", message: "no such room" });
|
if (!room) return send(socket, { type: "error", message: "no such room" });
|
||||||
const result = joinRoom(room, name, typeof msg.token === "string" ? msg.token : null);
|
const result = joinRoom(room, name, typeof msg.token === "string" ? msg.token : null);
|
||||||
if ("error" in result) {
|
if ("error" in result) {
|
||||||
// A refused seat is worth a line: a lost seat mid-game is the
|
// Logged: a refused seat is otherwise invisible after the fact.
|
||||||
// costliest quiet failure this table has.
|
|
||||||
console.warn(`join refused: room ${room.id} name ${JSON.stringify(name)} — ${result.error}`);
|
console.warn(`join refused: room ${room.id} name ${JSON.stringify(name)} — ${result.error}`);
|
||||||
return send(socket, { type: "error", message: result.error });
|
return send(socket, { type: "error", message: result.error });
|
||||||
}
|
}
|
||||||
@@ -800,19 +813,14 @@ wss.on("connection", (socket, req) => {
|
|||||||
}
|
}
|
||||||
const called = callKeeper(room, session.playerId, KEEPER);
|
const called = callKeeper(room, session.playerId, KEEPER);
|
||||||
if ("error" in called) return send(socket, { type: "error", message: called.error });
|
if ("error" in called) return send(socket, { type: "error", message: called.error });
|
||||||
// The alarm the keeper listens for: one issue per room, so each
|
// One issue per room, so each call rings once, with the door in
|
||||||
// call rings once, with the door in the message. Error level,
|
// the message.
|
||||||
// because Sentry's alerts ring for high-priority issues and a
|
|
||||||
// warning is filed as medium — a bell nobody hears.
|
|
||||||
const link = `${PUBLIC_URL}/join/${room.id}`;
|
const link = `${PUBLIC_URL}/join/${room.id}`;
|
||||||
if (process.env.SENTRY_DSN) {
|
ringBell(`${session.playerId} challenges ${KEEPER} to a game — ${link}`, {
|
||||||
Sentry.captureMessage(`${session.playerId} challenges ${KEEPER} to a game — ${link}`, {
|
fingerprint: ["challenge", room.id],
|
||||||
level: "error",
|
tags: { room: room.id, challenger: session.playerId },
|
||||||
fingerprint: ["challenge", room.id],
|
extra: { link, players: room.players.join(", ") },
|
||||||
tags: { room: room.id, challenger: session.playerId },
|
});
|
||||||
extra: { link, players: room.players.join(", ") },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const said = addChat(room, session.playerId, `calls ${KEEPER} to the table`);
|
const said = addChat(room, session.playerId, `calls ${KEEPER} to the table`);
|
||||||
if (!("error" in said)) broadcast(room, () => ({ type: "chat", player: session.playerId, text: said.text, at: said.at }));
|
if (!("error" in said)) broadcast(room, () => ({ type: "chat", player: session.playerId, text: said.text, at: said.at }));
|
||||||
broadcastRoomState(room);
|
broadcastRoomState(room);
|
||||||
@@ -1066,15 +1074,12 @@ wss.on("connection", (socket, req) => {
|
|||||||
happened,
|
happened,
|
||||||
expected: clean(msg.expected),
|
expected: clean(msg.expected),
|
||||||
});
|
});
|
||||||
// The desk's bell: one issue per report, the gist in the title.
|
// One issue per report, the gist in the title.
|
||||||
if (process.env.SENTRY_DSN) {
|
ringBell(`Report from ${session.playerId ?? "the gallery"} in ${room.id}: ${happened.slice(0, 100)}`, {
|
||||||
Sentry.captureMessage(`Report from ${session.playerId ?? "the gallery"} in ${room.id}: ${happened.slice(0, 100)}`, {
|
fingerprint: ["report", reportId],
|
||||||
level: "error",
|
tags: { room: room.id, report: reportId, player: session.playerId ?? "(gallery)" },
|
||||||
fingerprint: ["report", reportId],
|
extra: { happened, expected: clean(msg.expected), link: `${PUBLIC_URL}/join/${room.id}`, seq: room.log.length },
|
||||||
tags: { room: room.id, report: reportId, player: session.playerId ?? "(gallery)" },
|
});
|
||||||
extra: { happened, expected: clean(msg.expected), link: `${PUBLIC_URL}/join/${room.id}`, seq: room.log.length },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
send(socket, { type: "feedbackReceived", id: reportId });
|
send(socket, { type: "feedbackReceived", id: reportId });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1082,6 +1087,9 @@ wss.on("connection", (socket, req) => {
|
|||||||
// A player's word under the desk's reply, from their lobby: the
|
// A player's word under the desk's reply, from their lobby: the
|
||||||
// seat is proven as the ledger proves it, and the report must be
|
// seat is proven as the ledger proves it, and the report must be
|
||||||
// theirs. Each answer rings the desk's bell.
|
// theirs. Each answer rings the desk's bell.
|
||||||
|
if (!reportsPerAddress.allow(session.address)) {
|
||||||
|
return send(socket, { type: "error", message: "the desk has plenty from you for now — more in an hour" });
|
||||||
|
}
|
||||||
const seat = (msg.seat ?? {}) as Record<string, unknown>;
|
const seat = (msg.seat ?? {}) as Record<string, unknown>;
|
||||||
const roomId = String(seat.roomId ?? "").toUpperCase();
|
const roomId = String(seat.roomId ?? "").toUpperCase();
|
||||||
const name = String(seat.name ?? "");
|
const name = String(seat.name ?? "");
|
||||||
@@ -1092,19 +1100,13 @@ wss.on("connection", (socket, req) => {
|
|||||||
if (!report || report.player !== name || report.roomId !== roomId) return send(socket, { type: "error", message: "no such report of yours" });
|
if (!report || report.player !== name || report.roomId !== roomId) return send(socket, { type: "error", message: "no such report of yours" });
|
||||||
const text = String(msg.text ?? "").replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, " ").trim().slice(0, 2000);
|
const text = String(msg.text ?? "").replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, " ").trim().slice(0, 2000);
|
||||||
if (!text) return send(socket, { type: "error", message: "say something" });
|
if (!text) return send(socket, { type: "error", message: "say something" });
|
||||||
if (!reportsPerAddress.allow(session.address)) {
|
|
||||||
return send(socket, { type: "error", message: "the desk has plenty from you for now — more in an hour" });
|
|
||||||
}
|
|
||||||
const at = new Date().toISOString();
|
const at = new Date().toISOString();
|
||||||
appendFeedback({ reportId, from: "player", player: name, text, at });
|
appendFeedback({ reportId, from: "player", player: name, text, at });
|
||||||
if (process.env.SENTRY_DSN) {
|
ringBell(`${name} answers on report ${reportId} (${roomId}): ${text.slice(0, 100)}`, {
|
||||||
Sentry.captureMessage(`${name} answers on report ${reportId} (${roomId}): ${text.slice(0, 100)}`, {
|
fingerprint: ["report-answer", reportId, at],
|
||||||
level: "error",
|
tags: { room: roomId, report: reportId, player: name },
|
||||||
fingerprint: ["report-answer", reportId, at],
|
extra: { text, link: `${PUBLIC_URL}/join/${roomId}` },
|
||||||
tags: { room: roomId, report: reportId, player: name },
|
});
|
||||||
extra: { text, link: `${PUBLIC_URL}/join/${roomId}` },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
send(socket, { type: "feedbackReceived" });
|
send(socket, { type: "feedbackReceived" });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
|
import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
|
actingSeat as engineActingSeat,
|
||||||
applyCommand,
|
applyCommand,
|
||||||
automatonCommand,
|
automatonCommand,
|
||||||
automatonFallback,
|
automatonFallback,
|
||||||
@@ -62,6 +63,16 @@ export interface Room {
|
|||||||
|
|
||||||
const rooms = new Map<string, Room>();
|
const rooms = new Map<string, Room>();
|
||||||
|
|
||||||
|
/** Six wizards fit at a table: the physical set's standees. */
|
||||||
|
export const MAX_SEATS = 6;
|
||||||
|
/** The keeper of this table: the wizard a lobby may challenge. */
|
||||||
|
export const KEEPER = process.env.WIZWAR_KEEPER ?? "Kestrel";
|
||||||
|
|
||||||
|
/** Hold a seat for the keeper among the expected, once. */
|
||||||
|
function expectKeeper(room: Room, keeper: PlayerId): void {
|
||||||
|
room.expected = [...(room.expected ?? []).filter((n) => n !== keeper), keeper];
|
||||||
|
}
|
||||||
|
|
||||||
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
|
||||||
/** Tokens live hashed at rest (memory and disk); clients hold the raw form. */
|
/** Tokens live hashed at rest (memory and disk); clients hold the raw form. */
|
||||||
@@ -141,10 +152,10 @@ export function callKeeper(room: Room, byId: PlayerId, keeper: PlayerId): { at:
|
|||||||
if (!room.players.includes(byId) || room.bots.has(byId)) return { error: "take a seat first" };
|
if (!room.players.includes(byId) || room.bots.has(byId)) return { error: "take a seat first" };
|
||||||
if (room.challenge) return { error: `${keeper} has already been called to this table` };
|
if (room.challenge) return { error: `${keeper} has already been called to this table` };
|
||||||
if (room.players.includes(keeper)) return { error: `${keeper} is already here` };
|
if (room.players.includes(keeper)) return { error: `${keeper} is already here` };
|
||||||
if (room.players.length + (room.expected?.length ?? 0) >= 6) return { error: "the table is full" };
|
if (room.players.length + (room.expected?.length ?? 0) >= MAX_SEATS) return { error: "room is full" };
|
||||||
const at = new Date().toISOString();
|
const at = new Date().toISOString();
|
||||||
room.challenge = { by: byId, at };
|
room.challenge = { by: byId, at };
|
||||||
room.expected = [...(room.expected ?? []).filter((n) => n !== keeper), keeper];
|
expectKeeper(room, keeper);
|
||||||
appendLine(room.id, { kind: "challenge", by: byId, at });
|
appendLine(room.id, { kind: "challenge", by: byId, at });
|
||||||
return { at };
|
return { at };
|
||||||
}
|
}
|
||||||
@@ -290,7 +301,7 @@ export function joinRoom(
|
|||||||
return { error: "that wizard name is taken in this room" };
|
return { error: "that wizard name is taken in this room" };
|
||||||
}
|
}
|
||||||
if (room.state) return { error: "game already started" };
|
if (room.state) return { error: "game already started" };
|
||||||
if (room.players.length >= 6) return { error: "room is full" };
|
if (room.players.length >= MAX_SEATS) return { error: "room is full" };
|
||||||
const fresh = randomBytes(16).toString("hex");
|
const fresh = randomBytes(16).toString("hex");
|
||||||
room.players.push(playerId);
|
room.players.push(playerId);
|
||||||
room.tokens.set(playerId, hashToken(fresh));
|
room.tokens.set(playerId, hashToken(fresh));
|
||||||
@@ -431,7 +442,7 @@ export function addAutomaton(
|
|||||||
tierWanted?: string,
|
tierWanted?: string,
|
||||||
): { name: PlayerId } | { error: string } {
|
): { name: PlayerId } | { error: string } {
|
||||||
if (room.state) return { error: "the game has started" };
|
if (room.state) return { error: "the game has started" };
|
||||||
if (room.players.length >= 6) return { error: "room is full" };
|
if (room.players.length >= MAX_SEATS) return { error: "room is full" };
|
||||||
const name = AUTOMATON_NAMES.find((n) => !room.players.includes(n));
|
const name = AUTOMATON_NAMES.find((n) => !room.players.includes(n));
|
||||||
if (!name) return { error: "the workshop is empty" };
|
if (!name) return { error: "the workshop is empty" };
|
||||||
const known = AUTOMATON_STYLES.includes(styleWanted as AutomatonStyle);
|
const known = AUTOMATON_STYLES.includes(styleWanted as AutomatonStyle);
|
||||||
@@ -451,17 +462,7 @@ export function addAutomaton(
|
|||||||
/** Whose input does the maze want right now? */
|
/** Whose input does the maze want right now? */
|
||||||
function actingSeat(room: Room): PlayerId | null {
|
function actingSeat(room: Room): PlayerId | null {
|
||||||
const s = room.state;
|
const s = room.state;
|
||||||
if (!s || s.phase !== "playing") return null;
|
return s && s.phase === "playing" ? engineActingSeat(s) : null;
|
||||||
return (
|
|
||||||
s.wardPending?.ownerId ??
|
|
||||||
s.pushPending?.pusheeId ??
|
|
||||||
s.slowDeathPending?.playerId ??
|
|
||||||
s.stack?.waitingOn ??
|
|
||||||
s.pendingDiscard ??
|
|
||||||
s.chaosPending?.queue[0] ??
|
|
||||||
s.outOfTurnWindow?.playerId ??
|
|
||||||
s.players[s.turn.activeIndex]!.id
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -827,8 +828,7 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null {
|
|||||||
room.rematch = { roomId: line.to, by: line.by };
|
room.rematch = { roomId: line.to, by: line.by };
|
||||||
} else if (line.kind === "challenge") {
|
} else if (line.kind === "challenge") {
|
||||||
room.challenge = { by: line.by, at: line.at };
|
room.challenge = { by: line.by, at: line.at };
|
||||||
const keeper = process.env.WIZWAR_KEEPER ?? "Kestrel";
|
if (!room.players.includes(KEEPER)) expectKeeper(room, KEEPER);
|
||||||
if (!room.players.includes(keeper)) room.expected = [...(room.expected ?? []).filter((n) => n !== keeper), keeper];
|
|
||||||
} else if (line.kind === "chat") {
|
} else if (line.kind === "chat") {
|
||||||
// File order preserves the interleaving with commands.
|
// File order preserves the interleaving with commands.
|
||||||
room.chat.push({ player: line.player, text: line.text, at: line.at });
|
room.chat.push({ player: line.player, text: line.text, at: line.at });
|
||||||
|
|||||||
@@ -220,8 +220,6 @@ export interface FeedbackReport {
|
|||||||
round: number | null;
|
round: number | null;
|
||||||
happened: string;
|
happened: string;
|
||||||
expected: string;
|
expected: string;
|
||||||
/** The latest word from the desk, for the lobby's one-line summary. */
|
|
||||||
reply?: { at: string; text: string; status: string };
|
|
||||||
/** The whole exchange in order: the desk's replies and the player's answers. */
|
/** The whole exchange in order: the desk's replies and the player's answers. */
|
||||||
thread: { at: string; text: string; from: "desk" | "player"; status?: string }[];
|
thread: { at: string; text: string; from: "desk" | "player"; status?: string }[];
|
||||||
/** A screenshot's file name under feedback-images/, when one was sent. */
|
/** A screenshot's file name under feedback-images/, when one was sent. */
|
||||||
@@ -248,9 +246,7 @@ export function readFeedback(): FeedbackReport[] {
|
|||||||
} else if (report && line.from === "player") {
|
} else if (report && line.from === "player") {
|
||||||
report.thread.push({ at: String(line.at ?? ""), text: String(line.text ?? ""), from: "player" });
|
report.thread.push({ at: String(line.at ?? ""), text: String(line.text ?? ""), from: "player" });
|
||||||
} else if (report) {
|
} else if (report) {
|
||||||
const reply = { at: String(line.at ?? ""), text: String(line.text ?? ""), status: String(line.status ?? "resolved") };
|
report.thread.push({ at: String(line.at ?? ""), text: String(line.text ?? ""), status: String(line.status ?? "resolved"), from: "desk" });
|
||||||
report.reply = reply;
|
|
||||||
report.thread.push({ ...reply, from: "desk" });
|
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-39
@@ -9,7 +9,7 @@
|
|||||||
import Faq from "./Faq.svelte";
|
import Faq from "./Faq.svelte";
|
||||||
import Card from "./Card.svelte";
|
import Card from "./Card.svelte";
|
||||||
import Help from "./Help.svelte";
|
import Help from "./Help.svelte";
|
||||||
import type { HelpTab } from "./help-state";
|
import { HELP_TABS, type HelpTab } from "./help-state";
|
||||||
import { summarizeMissed } from "./catchup";
|
import { summarizeMissed } from "./catchup";
|
||||||
import Replay from "./Replay.svelte";
|
import Replay from "./Replay.svelte";
|
||||||
import FxGallery from "./FxGallery.svelte";
|
import FxGallery from "./FxGallery.svelte";
|
||||||
@@ -299,7 +299,7 @@
|
|||||||
const q = new URLSearchParams(location.search);
|
const q = new URLSearchParams(location.search);
|
||||||
const card = q.get("card");
|
const card = q.get("card");
|
||||||
const help = q.get("help");
|
const help = q.get("help");
|
||||||
const tabs: HelpTab[] = ["play", "rules", "cards", "rulings", "about", "tally"];
|
const tabs: readonly HelpTab[] = HELP_TABS;
|
||||||
if (card) { helpCard = card; showHelp = true; }
|
if (card) { helpCard = card; showHelp = true; }
|
||||||
else if (help) {
|
else if (help) {
|
||||||
const [t, ...rest] = help.split("/");
|
const [t, ...rest] = help.split("/");
|
||||||
@@ -1720,9 +1720,6 @@
|
|||||||
cellKey(p.position) === cellKey(me!.position))
|
cellKey(p.position) === cellKey(me!.position))
|
||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
/** Squares a selected L.O.S./ADJACENT card can reach; null = no dimming. */
|
|
||||||
// While an LOS attack sits on the stack, draw the line it traveled — the
|
|
||||||
// answer to "how can he even see me?" when sight ran through a warp mouth.
|
|
||||||
/** The attack on the stack draws its sight line; a cast that resolved
|
/** The attack on the stack draws its sight line; a cast that resolved
|
||||||
* at once (a creation, a curse) draws the line it was accepted on for a
|
* at once (a creation, a curse) draws the line it was accepted on for a
|
||||||
* few seconds, so the table can see how the aim was legal. */
|
* few seconds, so the table can see how the aim was legal. */
|
||||||
@@ -1839,6 +1836,26 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#snippet sayBox(extra: string, withDie: boolean)}
|
||||||
|
<form class="say-box {extra}" onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const t = chatDraft.trim();
|
||||||
|
if (!t) return;
|
||||||
|
if (net.sendChat(t)) chatDraft = "";
|
||||||
|
else net.flash("Not sent — the table is out of reach; your words wait in the box");
|
||||||
|
}}>
|
||||||
|
{#if net.chatPending}<span class="sending">sending…</span>{/if}
|
||||||
|
<input class="say-input" bind:value={chatDraft} maxlength="300"
|
||||||
|
placeholder="say something to the table…" aria-label="table talk" />
|
||||||
|
<button class="stamp tiny" type="submit" disabled={!chatDraft.trim()}>say</button>
|
||||||
|
{#if withDie}
|
||||||
|
<button class="stamp tiny" type="button" title="roll the die (house calls)"
|
||||||
|
onclick={() => net.rollTableDie()}>🎲</button>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
|
||||||
<svelte:window onkeydown={onBoardKey} />
|
<svelte:window onkeydown={onBoardKey} />
|
||||||
|
|
||||||
{#if fpvWorkshop || tokenWorkshop || fxWorkshop}
|
{#if fpvWorkshop || tokenWorkshop || fxWorkshop}
|
||||||
@@ -2581,23 +2598,24 @@
|
|||||||
{#if net.feedbackReports.length > 0}
|
{#if net.feedbackReports.length > 0}
|
||||||
<div class="reports-head">your reports to the wizards</div>
|
<div class="reports-head">your reports to the wizards</div>
|
||||||
{#each net.feedbackReports as r (r.id)}
|
{#each net.feedbackReports as r (r.id)}
|
||||||
|
{@const thread = r.thread ?? []}
|
||||||
<div class="report-row">
|
<div class="report-row">
|
||||||
<span class="ledger-code">{r.roomId}</span>
|
<span class="ledger-code">{r.roomId}</span>
|
||||||
<div class="report-body">
|
<div class="report-body">
|
||||||
<div class="report-text">“{r.happened}”{#if r.image}<span class="report-pic" title="with your screenshot"> 📷</span>{/if}</div>
|
<div class="report-text">“{r.happened}”{#if r.image}<span class="report-pic" title="with your screenshot"> 📷</span>{/if}</div>
|
||||||
{#each r.thread ?? [] as line, j (j)}
|
{#each thread as line, j (j)}
|
||||||
{#if line.from === "player"}
|
{#if line.from === "player"}
|
||||||
<div class="report-reply yours">you — {line.text}</div>
|
<div class="report-reply yours">you — {line.text}</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="report-reply"><span class="report-status">{line.status}</span> — {line.text}</div>
|
<div class="report-reply"><span class="report-status">{line.status}</span> — {line.text}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{#if !(r.thread ?? []).length}
|
{#if !thread.length}
|
||||||
<div class="report-reply pending">the wizards are studying the moment…</div>
|
<div class="report-reply pending">the wizards are studying the moment…</div>
|
||||||
{:else if (r.thread ?? [])[(r.thread ?? []).length - 1]!.from === "player"}
|
{:else if thread[thread.length - 1]!.from === "player"}
|
||||||
<div class="report-reply pending">your word is with the wizards…</div>
|
<div class="report-reply pending">your word is with the wizards…</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if (r.thread ?? []).some((l) => l.from === "desk")}
|
{#if thread.some((l) => l.from === "desk")}
|
||||||
<form class="report-answer" onsubmit={(e) => {
|
<form class="report-answer" onsubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const t = (reportAnswers[r.id] ?? "").trim();
|
const t = (reportAnswers[r.id] ?? "").trim();
|
||||||
@@ -2715,7 +2733,7 @@
|
|||||||
<summary>the link itself, for pasting by hand</summary>
|
<summary>the link itself, for pasting by hand</summary>
|
||||||
<code class="invite-link">{inviteLink}</code>
|
<code class="invite-link">{inviteLink}</code>
|
||||||
</details>
|
</details>
|
||||||
{#if !net.challenge && !net.players.includes(net.keeper) && net.players.length + net.expected.length < 6}
|
{#if net.keeper && !net.challenge && !net.players.includes(net.keeper) && net.players.length + net.expected.length < 6}
|
||||||
<div class="challenge-row">
|
<div class="challenge-row">
|
||||||
<button class="stamp quiet-lid" onclick={() => net.challengeKeeper()}
|
<button class="stamp quiet-lid" onclick={() => net.challengeKeeper()}
|
||||||
title="a raven flies to {net.keeper}, who keeps this table">
|
title="a raven flies to {net.keeper}, who keeps this table">
|
||||||
@@ -2735,16 +2753,7 @@
|
|||||||
{#each lobbyTalk as line, i (i)}
|
{#each lobbyTalk as line, i (i)}
|
||||||
<div class="lobby-line">{line.text}</div>
|
<div class="lobby-line">{line.text}</div>
|
||||||
{/each}
|
{/each}
|
||||||
<form class="say-box lobby-say" onsubmit={(e) => {
|
{@render sayBox("lobby-say", false)}
|
||||||
e.preventDefault();
|
|
||||||
const t = chatDraft.trim();
|
|
||||||
if (!t) return;
|
|
||||||
if (net.sendChat(t)) chatDraft = "";
|
|
||||||
else net.flash("Not sent — the table is out of reach; your words wait in the box");
|
|
||||||
}}>
|
|
||||||
<input class="say-input" bind:value={chatDraft} maxlength="300" placeholder="say something to the table…" aria-label="table talk" />
|
|
||||||
<button class="stamp tiny" type="submit" disabled={!chatDraft.trim()}>say</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
{#if net.you === net.hostId && addingBot && net.players.length < 6}
|
{#if net.you === net.hostId && addingBot && net.players.length < 6}
|
||||||
<div class="bot-panel">
|
<div class="bot-panel">
|
||||||
@@ -3283,6 +3292,7 @@
|
|||||||
<div class="scoresheet">
|
<div class="scoresheet">
|
||||||
<div class="scoresheet-head">life-points</div>
|
<div class="scoresheet-head">life-points</div>
|
||||||
{#each view.players as p (p.id)}
|
{#each view.players as p (p.id)}
|
||||||
|
{@const home = treasuresHomeOf(p)}
|
||||||
<div class="score-row" class:dead={!p.alive} class:active={p.id === view.activePlayerId}>
|
<div class="score-row" class:dead={!p.alive} class:active={p.id === view.activePlayerId}>
|
||||||
<span class="dot" style:background={playerColor(p.id)}></span>
|
<span class="dot" style:background={playerColor(p.id)}></span>
|
||||||
<span class="score-name">
|
<span class="score-name">
|
||||||
@@ -3291,7 +3301,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="score-life">{p.life}</span>
|
<span class="score-life">{p.life}</span>
|
||||||
<span class="score-marks">
|
<span class="score-marks">
|
||||||
{p.handCount} cards{#if p.lostTurns > 0} · dazed {p.lostTurns}{/if}{#if treasuresHomeOf(p) > 0} · <span class="score-home" class:threat={treasuresHomeOf(p) === 1 && !!p.carriedTreasureId} title={treasuresHomeOf(p) === 1 && p.carriedTreasureId ? "one treasure home and carrying the second — a step from winning" : "stolen treasures carried home"}>🏆 {treasuresHomeOf(p)} home</span>{/if}{#if p.carriedTreasureId}{@const ct = view.treasures.find((t) => t.id === p.carriedTreasureId)}{@const chestSrc = `/tokens-svg/treasure-${colorIndexOf(view, ct?.owner ?? p.id) % 6}.svg`} ·
|
{p.handCount} cards{#if p.lostTurns > 0} · dazed {p.lostTurns}{/if}{#if home > 0} · <span class="score-home" class:threat={home === 1 && !!p.carriedTreasureId} title={home === 1 && p.carriedTreasureId ? "one treasure home and carrying the second — a step from winning" : "stolen treasures carried home"}>🏆 {home} home</span>{/if}{#if p.carriedTreasureId}{@const ct = view.treasures.find((t) => t.id === p.carriedTreasureId)}{@const chestSrc = `/tokens-svg/treasure-${colorIndexOf(view, ct?.owner ?? p.id) % 6}.svg`} ·
|
||||||
<button class="score-chest-btn" aria-label="see the carried treasure"
|
<button class="score-chest-btn" aria-label="see the carried treasure"
|
||||||
onclick={() => (peekTreasure = { src: chestSrc, note: `${ct?.owner ?? "someone"}'s treasure — in ${p.id}'s arms` })}>
|
onclick={() => (peekTreasure = { src: chestSrc, note: `${ct?.owner ?? "someone"}'s treasure — in ${p.id}'s arms` })}>
|
||||||
<img class="score-chest" alt="" src={chestSrc} /></button>{/if}
|
<img class="score-chest" alt="" src={chestSrc} /></button>{/if}
|
||||||
@@ -3420,20 +3430,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !local.active && net.roomId && !net.spectating}
|
{#if !local.active && net.roomId && !net.spectating}
|
||||||
<form class="say-box" onsubmit={(e) => {
|
{@render sayBox("", true)}
|
||||||
e.preventDefault();
|
|
||||||
const t = chatDraft.trim();
|
|
||||||
if (!t) return;
|
|
||||||
if (net.sendChat(t)) chatDraft = "";
|
|
||||||
else net.flash("Not sent — the table is out of reach; your words wait in the box");
|
|
||||||
}}>
|
|
||||||
{#if net.chatPending}<span class="sending">sending…</span>{/if}
|
|
||||||
<input class="say-input" bind:value={chatDraft} maxlength="300"
|
|
||||||
placeholder="say something to the table…" aria-label="table talk" />
|
|
||||||
<button class="stamp tiny" type="submit" disabled={!chatDraft.trim()}>say</button>
|
|
||||||
<button class="stamp tiny" type="button" title="roll the die (house calls)"
|
|
||||||
onclick={() => net.rollTableDie()}>🎲</button>
|
|
||||||
</form>
|
|
||||||
{/if}
|
{/if}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -3531,7 +3528,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{#if net.pending}
|
{#if net.pending}
|
||||||
<span class="sending" aria-live="polite">{Date.now() - net.pending.at > 4000 ? "still sending" : "sending"} {net.pending.label}…</span>
|
<span class="sending" aria-live="polite">sending {net.pending.label}…</span>
|
||||||
{:else if net.confirmed}
|
{:else if net.confirmed}
|
||||||
<span class="sending ok" aria-live="polite">✓ {net.confirmed} confirmed</span>
|
<span class="sending ok" aria-live="polite">✓ {net.confirmed} confirmed</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -3845,9 +3842,9 @@
|
|||||||
background: #f4eede;
|
background: #f4eede;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
.standee img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
.standee img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
.standee { position: relative; }
|
|
||||||
.standee.current { border-color: var(--ring); box-shadow: 0 0 0 2px rgba(0,0,0,0.15); }
|
.standee.current { border-color: var(--ring); box-shadow: 0 0 0 2px rgba(0,0,0,0.15); }
|
||||||
.standee.current::after {
|
.standee.current::after {
|
||||||
content: "✓";
|
content: "✓";
|
||||||
@@ -3945,7 +3942,7 @@
|
|||||||
color: #3a2f1f;
|
color: #3a2f1f;
|
||||||
margin: 0.2rem 0 0.1rem;
|
margin: 0.2rem 0 0.1rem;
|
||||||
}
|
}
|
||||||
/* The eyes toggle floats over the board's corner so it costs the maze no height. */
|
/* The board's toolbar: the eyes toggle and the zoom, one short row. */
|
||||||
.board-tools {
|
.board-tools {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -4380,7 +4377,8 @@
|
|||||||
.fpv-primary .board-viewport { flex: 0 1 30dvh; min-height: 5rem; }
|
.fpv-primary .board-viewport { flex: 0 1 30dvh; min-height: 5rem; }
|
||||||
.fpv-primary .board-frame { min-height: 0; }
|
.fpv-primary .board-frame { min-height: 0; }
|
||||||
/* The pane keeps its aspect but never more height than the row can
|
/* The pane keeps its aspect but never more height than the row can
|
||||||
spare above the board strip and the dock. */
|
spare above the board strip and the dock: the strip's 5rem floor,
|
||||||
|
the dock, the hand, and the toolbar come to 34.5rem. */
|
||||||
.fpv-primary :global(.live-fp) { flex: 0 1 auto; min-height: 0; }
|
.fpv-primary :global(.live-fp) { flex: 0 1 auto; min-height: 0; }
|
||||||
.fpv-primary :global(.fpv-canvas) { width: auto; max-width: 100%; max-height: calc(100dvh - 34.5rem); margin: 0 auto; }
|
.fpv-primary :global(.fpv-canvas) { width: auto; max-width: 100%; max-height: calc(100dvh - 34.5rem); margin: 0 auto; }
|
||||||
.game:not(.hand-left) .table-edge { margin-top: 0.5rem; }
|
.game:not(.hand-left) .table-edge { margin-top: 0.5rem; }
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
import { RULEBOOK_COPYRIGHT } from "./rulebook";
|
import { RULEBOOK_COPYRIGHT } from "./rulebook";
|
||||||
import { helpState, type HelpTab } from "./help-state";
|
import { helpState, type HelpTab } from "./help-state";
|
||||||
import {
|
import {
|
||||||
REFERENCE, SOURCE_LABELS, searchReference, renderInline,
|
REFERENCE, SOURCE_LABELS, searchReference, renderInline, type RefSection,
|
||||||
CARD_POOL, searchCards, mentionedCards, sightOf, typeLabel, setLabel,
|
CARD_POOL, searchCards, mentionedCards, sightOf, typeLabel, setLabel,
|
||||||
HOUSE_RULINGS, rulingsFor, searchRulings,
|
HOUSE_RULINGS, rulingsFor, searchRulings,
|
||||||
type RefSource,
|
type RefSource,
|
||||||
@@ -129,6 +129,16 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#snippet section(s: RefSection, withSource: boolean)}
|
||||||
|
<h3 id={s.id} class="linked">
|
||||||
|
{s.title}
|
||||||
|
{#if withSource}<span class="source">{SOURCE_LABELS[s.source]}</span>{/if}
|
||||||
|
<button class="link-btn" onclick={() => copyLink("rules", s.id, null)} title="copy a link to this section">
|
||||||
|
{copied === `rules/${s.id}` ? "copied" : "🔗"}</button>
|
||||||
|
</h3>
|
||||||
|
{#each s.paragraphs as p, i (i)}<p>{@html renderInline(p)}</p>{/each}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<svelte:window {onkeydown} />
|
<svelte:window {onkeydown} />
|
||||||
|
|
||||||
{#if entryDef}
|
{#if entryDef}
|
||||||
@@ -277,7 +287,7 @@
|
|||||||
<a class="tile" href="/?fpv">
|
<a class="tile" href="/?fpv">
|
||||||
<img src="/hero.jpg" alt="" />
|
<img src="/hero.jpg" alt="" />
|
||||||
<span class="tile-name">The first-person workshop</span>
|
<span class="tile-name">The first-person workshop</span>
|
||||||
<span class="tile-note">the maze through a wizard's own eyes — or <span class="tile-link">watch the demo reel</span></span>
|
<span class="tile-note">the maze through a wizard's own eyes</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="colophon"><a href="/?fpv&demo=1">Watch the demo</a>: two clockwork wizards play a stretch, then the reel replays it.</p>
|
<p class="colophon"><a href="/?fpv&demo=1">Watch the demo</a>: two clockwork wizards play a stretch, then the reel replays it.</p>
|
||||||
@@ -358,13 +368,7 @@
|
|||||||
{#if rulesSearch.trim()}
|
{#if rulesSearch.trim()}
|
||||||
<p class="count">{refResults.length === 0 ? `Nothing in the rules mentions "${rulesSearch.trim()}".` : `${refResults.length} of ${REFERENCE.length} sections`}</p>
|
<p class="count">{refResults.length === 0 ? `Nothing in the rules mentions "${rulesSearch.trim()}".` : `${refResults.length} of ${REFERENCE.length} sections`}</p>
|
||||||
{#each refResults as s (s.id)}
|
{#each refResults as s (s.id)}
|
||||||
<h3 id={s.id} class="linked">
|
{@render section(s, true)}
|
||||||
{s.title}
|
|
||||||
<span class="source">{SOURCE_LABELS[s.source]}</span>
|
|
||||||
<button class="link-btn" onclick={() => copyLink("rules", s.id, null)} title="copy a link to this section">
|
|
||||||
{copied === `rules/${s.id}` ? "copied" : "🔗"}</button>
|
|
||||||
</h3>
|
|
||||||
{#each s.paragraphs as p, i (i)}<p>{@html renderInline(p)}</p>{/each}
|
|
||||||
{/each}
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
<nav class="contents" aria-label="contents">
|
<nav class="contents" aria-label="contents">
|
||||||
@@ -387,12 +391,7 @@
|
|||||||
<p class="colophon">Tom Jolly's own answers (wizwar.com, September 2002), verbatim. Rulings occasionally reference other editions. Card-specific rulings are on the cards themselves, in the library.</p>
|
<p class="colophon">Tom Jolly's own answers (wizwar.com, September 2002), verbatim. Rulings occasionally reference other editions. Card-specific rulings are on the cards themselves, in the library.</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#each group.sections as s (s.id)}
|
{#each group.sections as s (s.id)}
|
||||||
<h3 id={s.id} class="linked">
|
{@render section(s, false)}
|
||||||
{s.title}
|
|
||||||
<button class="link-btn" onclick={() => copyLink("rules", s.id, null)} title="copy a link to this section">
|
|
||||||
{copied === `rules/${s.id}` ? "copied" : "🔗"}</button>
|
|
||||||
</h3>
|
|
||||||
{#each s.paragraphs as p, i (i)}<p>{@html renderInline(p)}</p>{/each}
|
|
||||||
{/each}
|
{/each}
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -622,7 +621,6 @@
|
|||||||
.tile img { width: 100%; aspect-ratio: 16 / 10; object-fit: cover; border-radius: 3px; border: 1px solid #b3a687; background: #2a2622; }
|
.tile img { width: 100%; aspect-ratio: 16 / 10; object-fit: cover; border-radius: 3px; border: 1px solid #b3a687; background: #2a2622; }
|
||||||
.tile-name { font-family: "Oswald", sans-serif; font-size: 0.8rem; letter-spacing: 0.08em; text-transform: uppercase; }
|
.tile-name { font-family: "Oswald", sans-serif; font-size: 0.8rem; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||||
.tile-note { font-size: 0.85rem; color: #6b5a41; }
|
.tile-note { font-size: 0.85rem; color: #6b5a41; }
|
||||||
.tile-link { text-decoration: underline; text-decoration-style: dotted; }
|
|
||||||
|
|
||||||
.search-row { margin-bottom: 0.8rem; }
|
.search-row { margin-bottom: 0.8rem; }
|
||||||
.search-row input {
|
.search-row input {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { ReplayStep } from "./catchup";
|
||||||
import { motion } from "./motion";
|
import { motion } from "./motion";
|
||||||
import Board from "./Board.svelte";
|
import Board from "./Board.svelte";
|
||||||
import { wizardColor } from "./colors";
|
import { wizardColor } from "./colors";
|
||||||
@@ -22,7 +23,7 @@
|
|||||||
endLabel = null,
|
endLabel = null,
|
||||||
startAt = 0,
|
startAt = 0,
|
||||||
}: {
|
}: {
|
||||||
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[];
|
steps: ReplayStep[];
|
||||||
onclose: () => void;
|
onclose: () => void;
|
||||||
/** An instant replay of one turn: open straight into first person,
|
/** An instant replay of one turn: open straight into first person,
|
||||||
* through the eyes of the wizard whose turn it is. */
|
* through the eyes of the wizard whose turn it is. */
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { ReplayStep } from "./catchup";
|
||||||
// The public share page (/watch/<id>): one turn of one game, watchable
|
// The public share page (/watch/<id>): one turn of one game, watchable
|
||||||
// by anyone, spectator-redacted — the rest of the game stays private.
|
// by anyone, spectator-redacted — the rest of the game stays private.
|
||||||
// The reel opens in first person through the turn-owner's eyes, framed
|
// The reel opens in first person through the turn-owner's eyes, framed
|
||||||
@@ -11,7 +12,7 @@
|
|||||||
const API = location.port === "5173" ? `http://${location.hostname}:8787` : "";
|
const API = location.port === "5173" ? `http://${location.hostname}:8787` : "";
|
||||||
|
|
||||||
interface ShareSteps {
|
interface ShareSteps {
|
||||||
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView }[];
|
steps: ReplayStep[];
|
||||||
actor: string;
|
actor: string;
|
||||||
round: number;
|
round: number;
|
||||||
whole?: boolean;
|
whole?: boolean;
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
// read from the missed steps as the server redacted them for you, so it
|
// read from the missed steps as the server redacted them for you, so it
|
||||||
// tells you only what you would have seen at the table.
|
// tells you only what you would have seen at the table.
|
||||||
|
|
||||||
import { cardDef, type GameEvent, type GameView } from "@wizwar/engine";
|
import type { GameEvent, GameView } from "@wizwar/engine";
|
||||||
|
import { cardName } from "./reference";
|
||||||
|
|
||||||
export interface MissedStep {
|
/** One step of a replayed stretch: who acted, what happened, the table
|
||||||
|
* after, and any talk that landed alongside. */
|
||||||
|
export interface ReplayStep {
|
||||||
seq: number;
|
seq: number;
|
||||||
actor: string;
|
actor: string;
|
||||||
events: GameEvent[];
|
events: GameEvent[];
|
||||||
@@ -19,8 +22,7 @@ export interface MissedFact {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function spell(id: string | null): string {
|
function spell(id: string | null): string {
|
||||||
if (!id) return "a punch";
|
return id ? cardName(id) : "a punch";
|
||||||
try { return cardDef(id).name; } catch { return id; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function nearHome(view: GameView, you: string, cell: { x: number; y: number }): boolean {
|
function nearHome(view: GameView, you: string, cell: { x: number; y: number }): boolean {
|
||||||
@@ -31,7 +33,7 @@ function nearHome(view: GameView, you: string, cell: { x: number; y: number }):
|
|||||||
|
|
||||||
/** The facts of the missed steps that touch you, in order, capped so the
|
/** The facts of the missed steps that touch you, in order, capped so the
|
||||||
* slip stays a slip. Each fact points at its step for the reel. */
|
* slip stays a slip. Each fact points at its step for the reel. */
|
||||||
export function summarizeMissed(steps: readonly MissedStep[], you: string, now: GameView | null): MissedFact[] {
|
export function summarizeMissed(steps: readonly ReplayStep[], you: string, now: GameView | null): MissedFact[] {
|
||||||
const facts: MissedFact[] = [];
|
const facts: MissedFact[] = [];
|
||||||
let lifeLost = 0;
|
let lifeLost = 0;
|
||||||
let lifeLostStep = -1;
|
let lifeLostStep = -1;
|
||||||
@@ -77,6 +79,7 @@ export function summarizeMissed(steps: readonly MissedStep[], you: string, now:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// Life lost reads second: the first fact names who came for you.
|
||||||
if (lifeLost > 0) facts.splice(Math.min(facts.length, 1), 0, { text: `You lost ${lifeLost} life.`, step: lifeLostStep });
|
if (lifeLost > 0) facts.splice(Math.min(facts.length, 1), 0, { text: `You lost ${lifeLost} life.`, step: lifeLostStep });
|
||||||
if (facts.length === 0) {
|
if (facts.length === 0) {
|
||||||
// Nothing touched you: say what each wizard was up to, all the same.
|
// Nothing touched you: say what each wizard was up to, all the same.
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
|
|
||||||
import type { CardQuery } from "./reference";
|
import type { CardQuery } from "./reference";
|
||||||
|
|
||||||
export type HelpTab = "play" | "rules" | "cards" | "rulings" | "about" | "tally";
|
export const HELP_TABS = ["play", "rules", "cards", "rulings", "about", "tally"] as const;
|
||||||
|
export type HelpTab = (typeof HELP_TABS)[number];
|
||||||
|
|
||||||
export const helpState: {
|
export const helpState: {
|
||||||
tab: HelpTab;
|
tab: HelpTab;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Websocket client + reactive session state (Svelte 5 runes).
|
// Websocket client + reactive session state (Svelte 5 runes).
|
||||||
|
|
||||||
|
import type { ReplayStep } from "./catchup";
|
||||||
import type { Command, GameEvent, GameView, Side } from "@wizwar/engine";
|
import type { Command, GameEvent, GameView, Side } from "@wizwar/engine";
|
||||||
import { cardDef, isPermanentDuration } from "@wizwar/engine";
|
import { cardDef, isPermanentDuration } from "@wizwar/engine";
|
||||||
import { receiptFor } from "./receipt";
|
import { receiptFor } from "./receipt";
|
||||||
@@ -301,7 +302,6 @@ export interface FeedbackReportView {
|
|||||||
happened: string;
|
happened: string;
|
||||||
expected: string;
|
expected: string;
|
||||||
player?: string;
|
player?: string;
|
||||||
reply?: { at: string; text: string; status: string };
|
|
||||||
/** The exchange in order: the desk's replies and the player's answers. */
|
/** The exchange in order: the desk's replies and the player's answers. */
|
||||||
thread?: { at: string; text: string; from: "desk" | "player"; status?: string }[];
|
thread?: { at: string; text: string; from: "desk" | "player"; status?: string }[];
|
||||||
image?: string;
|
image?: string;
|
||||||
@@ -381,8 +381,8 @@ class Net {
|
|||||||
expected = $state<string[]>([]);
|
expected = $state<string[]>([]);
|
||||||
/** The table called the keeper of this site, and by whom. */
|
/** The table called the keeper of this site, and by whom. */
|
||||||
challenge = $state<{ by: string; at: string } | null>(null);
|
challenge = $state<{ by: string; at: string } | null>(null);
|
||||||
/** The keeper's name, as the server knows it. */
|
/** The keeper's name, once the room has said it. */
|
||||||
keeper = $state("Kestrel");
|
keeper = $state<string | null>(null);
|
||||||
view = $state<GameView | null>(null);
|
view = $state<GameView | null>(null);
|
||||||
log = $state<LogLine[]>([]);
|
log = $state<LogLine[]>([]);
|
||||||
error = $state<string | null>(null);
|
error = $state<string | null>(null);
|
||||||
@@ -410,15 +410,15 @@ class Net {
|
|||||||
/** Board flourishes: the app hooks in to animate live event batches. */
|
/** Board flourishes: the app hooks in to animate live event batches. */
|
||||||
onFx: ((events: GameEvent[]) => void) | null = null;
|
onFx: ((events: GameEvent[]) => void) | null = null;
|
||||||
/** A catch-up reel delivered by the server. */
|
/** A catch-up reel delivered by the server. */
|
||||||
catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
|
catchUp = $state<ReplayStep[] | null>(null);
|
||||||
/** The missed steps, fetched for the summary slip before any reel is opened. */
|
/** The missed steps, fetched for the summary slip before any reel is opened. */
|
||||||
missedSteps = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
|
missedSteps = $state<ReplayStep[] | null>(null);
|
||||||
/** Where the catch-up reel opens: the step a summary line pointed at. */
|
/** Where the catch-up reel opens: the step a summary line pointed at. */
|
||||||
catchUpStart = $state(0);
|
catchUpStart = $state(0);
|
||||||
private catchUpWanted: "summary" | "reel" = "reel";
|
private catchUpWanted: "summary" | "reel" = "reel";
|
||||||
/** One turn's reel, summoned from a chronicle line's instant-replay
|
/** One turn's reel, summoned from a chronicle line's instant-replay
|
||||||
* eye — steps plus the wizard whose eyes the camera wears. */
|
* eye — steps plus the wizard whose eyes the camera wears. */
|
||||||
moment = $state<{ steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[]; owner: string } | null>(null);
|
moment = $state<{ steps: ReplayStep[]; owner: string } | null>(null);
|
||||||
/** Turns witnessed so far: counts the same boundary events the server
|
/** Turns witnessed so far: counts the same boundary events the server
|
||||||
* counts, so a chronicle line can name the turn it belongs to. */
|
* counts, so a chronicle line can name the turn it belongs to. */
|
||||||
private turnCounter = -1;
|
private turnCounter = -1;
|
||||||
@@ -648,9 +648,7 @@ class Net {
|
|||||||
break;
|
break;
|
||||||
case "rematched":
|
case "rematched":
|
||||||
// The caller's own move: the seat and room for the new table follow.
|
// The caller's own move: the seat and room for the new table follow.
|
||||||
this.resetChronicle();
|
this.leaveTable();
|
||||||
this.view = null;
|
|
||||||
this.started = false;
|
|
||||||
this.roomId = null;
|
this.roomId = null;
|
||||||
this.roomIdPending = msg.roomId;
|
this.roomIdPending = msg.roomId;
|
||||||
break;
|
break;
|
||||||
@@ -693,8 +691,8 @@ class Net {
|
|||||||
// stays — a restarting server, a stale restore, or the wrong
|
// stays — a restarting server, a stale restore, or the wrong
|
||||||
// backend all answer with ignorance, and ignorance is not
|
// backend all answer with ignorance, and ignorance is not
|
||||||
// deletion. The wallet is capped by age instead of by trust.
|
// deletion. The wallet is capped by age instead of by trust.
|
||||||
// And a seat is dropped only on the SECOND refusal in a row: one
|
// A seat goes only on the SECOND refusal in a row: one refusal
|
||||||
// refusal around a restart or a stale answer must not cost a seat.
|
// around a restart or a stale answer must not cost a seat.
|
||||||
const voided = new Set((msg.voided as string[] | undefined) ?? []);
|
const voided = new Set((msg.voided as string[] | undefined) ?? []);
|
||||||
const doubted = new Set<string>();
|
const doubted = new Set<string>();
|
||||||
for (const key of voided) { if (this.voidedOnce.has(key)) doubted.add(key); }
|
for (const key of voided) { if (this.voidedOnce.has(key)) doubted.add(key); }
|
||||||
@@ -852,9 +850,7 @@ class Net {
|
|||||||
/** Take the seat kept for you at the rematch table. */
|
/** Take the seat kept for you at the rematch table. */
|
||||||
acceptRematch(roomId: string): void {
|
acceptRematch(roomId: string): void {
|
||||||
if (!this.you) return;
|
if (!this.you) return;
|
||||||
this.resetChronicle();
|
this.leaveTable();
|
||||||
this.view = null;
|
|
||||||
this.started = false;
|
|
||||||
this.join(roomId, this.you);
|
this.join(roomId, this.you);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -996,6 +992,13 @@ class Net {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The chronicle and its turn count reset together, always. */
|
/** The chronicle and its turn count reset together, always. */
|
||||||
|
/** Rise from a finished table: the chronicle, the view, and the game go. */
|
||||||
|
private leaveTable(): void {
|
||||||
|
this.resetChronicle();
|
||||||
|
this.view = null;
|
||||||
|
this.started = false;
|
||||||
|
}
|
||||||
|
|
||||||
private resetChronicle(): void {
|
private resetChronicle(): void {
|
||||||
this.log = [];
|
this.log = [];
|
||||||
this.turnCounter = -1;
|
this.turnCounter = -1;
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
// engine's own events, so it explains what happened rather than what the
|
// engine's own events, so it explains what happened rather than what the
|
||||||
// cards promise.
|
// cards promise.
|
||||||
|
|
||||||
import { cardDef, isPermanentDuration, type GameEvent } from "@wizwar/engine";
|
import { isPermanentDuration, type GameEvent } from "@wizwar/engine";
|
||||||
|
import { cardName } from "./reference";
|
||||||
|
|
||||||
export interface Receipt {
|
export interface Receipt {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -11,8 +12,7 @@ export interface Receipt {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function nameOf(id: string | null): string {
|
function nameOf(id: string | null): string {
|
||||||
if (!id) return "The punch";
|
return id ? cardName(id) : "The punch";
|
||||||
try { return cardDef(id).name; } catch { return id; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The receipt for a resolution event, or null when there is nothing to
|
/** The receipt for a resolution event, or null when there is nothing to
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ import { RULES_SECTIONS } from "./rules";
|
|||||||
import { RULEBOOK_BASE, RULEBOOK_EXPANSION } from "./rulebook";
|
import { RULEBOOK_BASE, RULEBOOK_EXPANSION } from "./rulebook";
|
||||||
import { FAQ_GENERAL } from "./faq-general";
|
import { FAQ_GENERAL } from "./faq-general";
|
||||||
|
|
||||||
|
/** A card's printed name, or the id itself for one the deck does not know. */
|
||||||
|
export function cardName(id: string): string {
|
||||||
|
try { return cardDef(id).name; } catch { return id; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRe(text: string): string {
|
||||||
|
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
/** A stable anchor from a title: "Line of Sight (L.O.S.)" → "line-of-sight-l-o-s". */
|
/** A stable anchor from a title: "Line of Sight (L.O.S.)" → "line-of-sight-l-o-s". */
|
||||||
export function slug(title: string): string {
|
export function slug(title: string): string {
|
||||||
return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||||
@@ -132,13 +141,13 @@ export function mentionedCards(cardId: string): CardDef[] {
|
|||||||
const def = cardDef(cardId);
|
const def = cardDef(cardId);
|
||||||
let text = def.text ?? "";
|
let text = def.text ?? "";
|
||||||
if (!text) return [];
|
if (!text) return [];
|
||||||
text = text.replace(new RegExp(`\\b${def.name.toUpperCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"), " ");
|
text = text.replace(new RegExp(`\\b${escapeRe(def.name.toUpperCase())}\\b`, "g"), " ");
|
||||||
const found: CardDef[] = [];
|
const found: CardDef[] = [];
|
||||||
const candidates = CARD_POOL
|
const candidates = CARD_POOL
|
||||||
.filter((d) => d.id !== cardId && d.name.length >= 4 && d.cardType !== "number")
|
.filter((d) => d.id !== cardId && d.name.length >= 4 && d.cardType !== "number")
|
||||||
.sort((a, b) => b.name.length - a.name.length);
|
.sort((a, b) => b.name.length - a.name.length);
|
||||||
for (const d of candidates) {
|
for (const d of candidates) {
|
||||||
const re = new RegExp(`\\b${d.name.toUpperCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
const re = new RegExp(`\\b${escapeRe(d.name.toUpperCase())}\\b`);
|
||||||
if (re.test(text)) {
|
if (re.test(text)) {
|
||||||
found.push(d);
|
found.push(d);
|
||||||
text = text.replace(new RegExp(re.source, "g"), " ");
|
text = text.replace(new RegExp(re.source, "g"), " ");
|
||||||
@@ -282,10 +291,6 @@ export const HOUSE_RULINGS: HouseRuling[] = [
|
|||||||
id: "waterwall", title: "The waterwall's wave", cards: ["waterwall"],
|
id: "waterwall", title: "The waterwall's wave", cards: ["waterwall"],
|
||||||
body: ["A waterwall's wave names its victims before it pushes any of them, so no wizard is caught twice by the same wave. One square into a wall costs one point."],
|
body: ["A waterwall's wave names its victims before it pushes any of them, so no wizard is caught twice by the same wave. One square into a wall costs one point."],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "lifesaver", title: "Lifesaver", cards: ["lifesaver"],
|
|
||||||
body: ["A wizard holding LIFESAVER is not eliminated for losing both treasures, exactly as the card promises."],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "force-field", title: "Force Field", cards: ["force-field"],
|
id: "force-field", title: "Force Field", cards: ["force-field"],
|
||||||
body: ["FORCE FIELD, after stopping the spell, stands until the end of the opponent's turn: they may not enter its caster's square, nor cast on or past them, on every side — where the card says one side, this table gives all four."],
|
body: ["FORCE FIELD, after stopping the spell, stands until the end of the opponent's turn: they may not enter its caster's square, nor cast on or past them, on every side — where the card says one side, this table gives all four."],
|
||||||
@@ -302,7 +307,7 @@ export function searchRulings(query: string): HouseRuling[] {
|
|||||||
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
if (words.length === 0) return HOUSE_RULINGS;
|
if (words.length === 0) return HOUSE_RULINGS;
|
||||||
return HOUSE_RULINGS.filter((r) => {
|
return HOUSE_RULINGS.filter((r) => {
|
||||||
const names = r.cards.map((id) => { try { return cardDef(id).name; } catch { return id; } });
|
const names = r.cards.map(cardName);
|
||||||
const hay = `${r.title}\n${r.body.join("\n")}\n${names.join("\n")}`.toLowerCase();
|
const hay = `${r.title}\n${r.body.join("\n")}\n${names.join("\n")}`.toLowerCase();
|
||||||
return words.every((w) => hay.includes(w));
|
return words.every((w) => hay.includes(w));
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user