diff --git a/src/logparser.js b/src/logparser.js new file mode 100644 index 0000000..6d80f17 --- /dev/null +++ b/src/logparser.js @@ -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] +// +// 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 !" + const selfKillM = content.match(/^You have slain (.+)!$/); + if (selfKillM) return { type: "kill", mob: selfKillM[1], slayer: null }; + + // Kill by named player/NPC: " has been slain by !" + const namedKillM = content.match(/^(.+) has been slain by (.+)!$/); + if (namedKillM) return { type: "kill", mob: namedKillM[1], slayer: namedKillM[2] }; + + return null; +} diff --git a/src/xp.js b/src/xp.js index 111f074..30dd956 100644 --- a/src/xp.js +++ b/src/xp.js @@ -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"; diff --git a/test/logparser.test.js b/test/logparser.test.js new file mode 100644 index 0000000..928f5a7 --- /dev/null +++ b/test/logparser.test.js @@ -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 }); +});