Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/logparser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Pure EverQuest log-line parser. No DOM; importable by the browser and
// node:test.
//
// EQ P99 log format:
// [Day Mon DD HH:MM:SS YYYY] <content>
//
// Chat lines — where a player quoted a log message inside /say, /shout, /ooc,
// /guild, /group, or /tell — are suppressed so their body is never mistaken
// for a real game event.

const TIMESTAMP_RE =
/^\[(?:Sun|Mon|Tue|Wed|Thu|Fri|Sat) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [ \d]\d \d{2}:\d{2}:\d{2} \d{4}\] /;

// Each regex is anchored to the start of the post-timestamp content.
const CHAT_RES = [
/^\S+ -> \S+: /, // tell (outgoing): "CharA -> CharB: ..."
/^\S+ tells you, '/, // tell (incoming): "Name tells you, '...'"
/^\S+ says, '/, // say — other: "Name says, '...'"
/^You say, '/, // say — self
/^\S+ shouts, '/, // shout — other
/^You shout, '/, // shout — self
/^\S+ says out of character, '/, // ooc — other
/^You say out of character, '/, // ooc — self
/^\S+ says to your guild, '/, // guild — other
/^You say to your guild, '/, // guild — self
/^\S+ tells the group, '/, // group — other
/^You tell the group, '/, // group — self
/^\S+ auctions, '/, // auction — other
/^You auction, '/, // auction — self
];

/**
* Returns true when `content` (the portion of a log line after the timestamp)
* is a chat-channel message. Such lines must not be parsed as game events
* because a player may have pasted event-like text into chat.
*
* @param {string} content post-timestamp line content
* @returns {boolean}
*/
export function isChat(content) {
return CHAT_RES.some((re) => re.test(content));
}

/**
* Parse one line from an EverQuest log file into a typed event object, or
* return null for lines that are not recognized game events (including all
* chat-channel lines).
*
* @param {string} line raw log line
* @returns {{ type: string } | null}
*/
export function parseLine(line) {
const m = line.match(TIMESTAMP_RE);
if (!m) return null;

const content = line.slice(m[0].length);
if (isChat(content)) return null;

// Level up: "You have gained a level! Welcome to level N!"
const levelUpM = content.match(/^You have gained a level! Welcome to level (\d+)!$/);
if (levelUpM) return { type: "level_up", level: parseInt(levelUpM[1], 10) };

// Kill by self: "You have slain <mob>!"
const selfKillM = content.match(/^You have slain (.+)!$/);
if (selfKillM) return { type: "kill", mob: selfKillM[1], slayer: null };

// Kill by named player/NPC: "<mob> has been slain by <slayer>!"
const namedKillM = content.match(/^(.+) has been slain by (.+)!$/);
if (namedKillM) return { type: "kill", mob: namedKillM[1], slayer: namedKillM[2] };

return null;
}
1 change: 1 addition & 0 deletions src/xp.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ export { partyXpForMob } from "./partyxp.js";
export { splitXp } from "./split.js";
export { awardXp } from "./award.js";
export { killsToNextLevel } from "./kills.js";
export { parseLine, isChat } from "./logparser.js";
206 changes: 206 additions & 0 deletions test/logparser.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseLine, isChat } from "../src/logparser.js";

// ── isChat ────────────────────────────────────────────────────────────────────

test("isChat: outgoing tell (CharA -> CharB: ...)", () => {
assert.equal(
isChat(
"MyChar -> TheirChar: and then Thu Jun 17 18:33:44 2021 You have gained a level! Welcome to level 59!",
),
true,
);
});

test("isChat: incoming tell (Name tells you, '...')", () => {
assert.equal(isChat("Gurob tells you, 'hello'"), true);
});

test("isChat: say — other player (Name says, '...')", () => {
assert.equal(isChat("Gurob says, 'You have gained a level! Welcome to level 59!'"), true);
});

test("isChat: say — self (You say, '...')", () => {
assert.equal(isChat("You say, 'You have slain a goblin!'"), true);
});

test("isChat: shout — other player (Name shouts, '...')", () => {
assert.equal(isChat("Gurob shouts, 'You have gained a level! Welcome to level 12!'"), true);
});

test("isChat: shout — self (You shout, '...')", () => {
assert.equal(isChat("You shout, 'You have gained a level! Welcome to level 12!'"), true);
});

test("isChat: ooc — other player (Name says out of character, '...')", () => {
assert.equal(isChat("Gurob says out of character, 'copy of a log line'"), true);
});

test("isChat: ooc — self (You say out of character, '...')", () => {
assert.equal(isChat("You say out of character, 'copy of a log line'"), true);
});

test("isChat: guild — other player (Name says to your guild, '...')", () => {
assert.equal(isChat("Gurob says to your guild, 'You have slain a rat!'"), true);
});

test("isChat: guild — self (You say to your guild, '...')", () => {
assert.equal(isChat("You say to your guild, 'You have slain a rat!'"), true);
});

test("isChat: group — other player (Name tells the group, '...')", () => {
assert.equal(
isChat("Gurob tells the group, 'You have gained a level! Welcome to level 59!'"),
true,
);
});

test("isChat: group — self (You tell the group, '...')", () => {
assert.equal(
isChat("You tell the group, 'You have gained a level! Welcome to level 59!'"),
true,
);
});

test("isChat: auction — other player (Name auctions, '...')", () => {
assert.equal(isChat("Gurob auctions, 'Torch PST'"), true);
});

test("isChat: auction — self (You auction, '...')", () => {
assert.equal(isChat("You auction, 'Torch 10pp'"), true);
});

test("isChat: returns false for a real level-up event", () => {
assert.equal(isChat("You have gained a level! Welcome to level 10!"), false);
});

test("isChat: returns false for a real kill event", () => {
assert.equal(isChat("You have slain a goblin!"), false);
});

// ── parseLine: chat lines suppressed ─────────────────────────────────────────

// The canonical case from the issue: a tell whose body contains text that
// looks exactly like a level-up log event.
test("parseLine: tell containing level-up text is null (user example)", () => {
const line =
"[Wed Jan 28 21:31:44 2026] MyChar -> TheirChar: and then Thu Jun 17 18:33:44 2021 You have gained a level! Welcome to level 59!";
assert.equal(parseLine(line), null);
});

test("parseLine: incoming tell containing level-up text is null", () => {
const line =
"[Thu Jun 17 18:33:44 2021] Gurob tells you, 'did you see Thu Jun 17 18:33:44 2021 You have gained a level! Welcome to level 59!'";
assert.equal(parseLine(line), null);
});

test("parseLine: say containing level-up text is null", () => {
const line =
"[Thu Jun 17 18:33:44 2021] Gurob says, 'You have gained a level! Welcome to level 10!'";
assert.equal(parseLine(line), null);
});

test("parseLine: self-say containing kill text is null", () => {
const line = "[Thu Jun 17 18:33:44 2021] You say, 'You have slain a goblin!'";
assert.equal(parseLine(line), null);
});

test("parseLine: shout containing level-up text is null", () => {
const line =
"[Thu Jun 17 18:33:44 2021] Gurob shouts, 'You have gained a level! Welcome to level 12!'";
assert.equal(parseLine(line), null);
});

test("parseLine: self-shout containing level-up text is null", () => {
const line =
"[Thu Jun 17 18:33:44 2021] You shout, 'You have gained a level! Welcome to level 12!'";
assert.equal(parseLine(line), null);
});

test("parseLine: ooc containing level-up text is null", () => {
const line =
"[Thu Jun 17 18:33:44 2021] Gurob says out of character, 'You have gained a level! Welcome to level 5!'";
assert.equal(parseLine(line), null);
});

test("parseLine: self-ooc containing level-up text is null", () => {
const line =
"[Thu Jun 17 18:33:44 2021] You say out of character, 'You have gained a level! Welcome to level 5!'";
assert.equal(parseLine(line), null);
});

test("parseLine: guild containing level-up text is null", () => {
const line =
"[Thu Jun 17 18:33:44 2021] Gurob says to your guild, 'You have gained a level! Welcome to level 20!'";
assert.equal(parseLine(line), null);
});

test("parseLine: self-guild containing level-up text is null", () => {
const line =
"[Thu Jun 17 18:33:44 2021] You say to your guild, 'You have gained a level! Welcome to level 20!'";
assert.equal(parseLine(line), null);
});

test("parseLine: group-tell containing level-up text is null", () => {
const line =
"[Thu Jun 17 18:33:44 2021] Gurob tells the group, 'You have gained a level! Welcome to level 59!'";
assert.equal(parseLine(line), null);
});

test("parseLine: self-group-tell containing level-up text is null", () => {
const line =
"[Thu Jun 17 18:33:44 2021] You tell the group, 'You have gained a level! Welcome to level 59!'";
assert.equal(parseLine(line), null);
});

test("parseLine: auction containing event-like text is null", () => {
const line = "[Thu Jun 17 18:33:44 2021] Gurob auctions, 'You have slain Nagafen!'";
assert.equal(parseLine(line), null);
});

test("parseLine: self-auction containing event-like text is null", () => {
const line = "[Thu Jun 17 18:33:44 2021] You auction, 'You have slain Nagafen!'";
assert.equal(parseLine(line), null);
});

// ── parseLine: real events ────────────────────────────────────────────────────

test("parseLine: level-up event", () => {
const line = "[Thu Jun 17 18:33:44 2021] You have gained a level! Welcome to level 10!";
assert.deepEqual(parseLine(line), { type: "level_up", level: 10 });
});

test("parseLine: level-up at level 59", () => {
const line = "[Thu Jun 17 18:33:44 2021] You have gained a level! Welcome to level 59!";
assert.deepEqual(parseLine(line), { type: "level_up", level: 59 });
});

test("parseLine: kill by self", () => {
const line = "[Wed Jan 28 21:31:44 2026] You have slain a goblin!";
assert.deepEqual(parseLine(line), { type: "kill", mob: "a goblin", slayer: null });
});

test("parseLine: kill — mob slain by named player", () => {
const line = "[Wed Jan 28 21:31:44 2026] a goblin has been slain by Gurob!";
assert.deepEqual(parseLine(line), { type: "kill", mob: "a goblin", slayer: "Gurob" });
});

// ── parseLine: edge cases ─────────────────────────────────────────────────────

test("parseLine: line with no timestamp is null", () => {
assert.equal(parseLine("You have gained a level! Welcome to level 10!"), null);
});

test("parseLine: empty string is null", () => {
assert.equal(parseLine(""), null);
});

test("parseLine: timestamp with unrecognized content is null", () => {
assert.equal(parseLine("[Wed Jan 28 21:31:44 2026] Something unknown happened."), null);
});

test("parseLine: single-digit day in timestamp is parsed", () => {
const line = "[Wed Jan 1 00:00:00 2020] You have gained a level! Welcome to level 2!";
assert.deepEqual(parseLine(line), { type: "level_up", level: 2 });
});
Loading