THE SPRITES. The match opponent borrowed the turned CPU's art — a body drawn to SHAMBLE at the camera with its racket hanging at its side, which is what was playing tennis points against you. It has its own family now, 'r', drawn off the LIVING opponent's poses: a ready stance, a shuffle pair and a real forehand, at every wound stage, men's and women's — 20 frames, six batches. 'c' keeps its shamble untouched, because in the gore mode shambling is exactly what the turned CPU should be doing. The pose comes from racketAI's own `pose`, so the picture cannot drift from the behaviour, and a one-armed opponent still plays tennis instead of reverting to a walk. Two pipeline bugs found doing it, both fixed at the root in assets/: - _fine.py keyed EVERY magenta pixel, not just the background, and the generator paints racket strings in the backdrop colour more often than not — so frame after frame installed a man holding an empty hoop. Background is now the region connected to the border; magenta left INSIDE a body is repainted pale grey rather than punched out. - the flood fill silently did nothing, because Image.fromarray images are read-only and PIL's floodfill swallows the write error. .copy() plus a real error if it ever fills zero pixels. THE LADDER (handoff slice 2). Rung N takes design N's name off the chapter roster — the club's dead staff play you at tennis here and come for you in gore, same joke twice — plus a personality that is DIALS, not new mechanics: pace, width, flub and speed scalings multiplying numbers racketAI already reads. A rung is a data row. Name plate on the walk-on, and the kill files under the rung rather than under the generic body. THE STATS SCREEN follows the mode split: BODY COUNT / THE LADDER / THE RECORD behind a mode strip, each with its own three figures, its own roster heading and its own footer. The weapon shelf and the roster hide in the modes that have neither, rather than printing a line about something the mode cannot do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
334 lines
20 KiB
JavaScript
334 lines
20 KiB
JavaScript
// stats.js — what the game remembers about you, and the two menu screens that read it back.
|
|
//
|
|
// Everything the BODY COUNT screen shows comes out of ONE localStorage key: an array of per-run
|
|
// records, newest last. ALL TIME / LAST MATCH / 7 DAYS are three filters over that array rather
|
|
// than three stored counters, so a period can be added later without a migration and without a
|
|
// second write path that can drift out of step with the first.
|
|
//
|
|
// The live run is a module-local bag, not a field on G. G is deep-cloned into PRISTINE at load and
|
|
// restored wholesale by restartRun(), so a counter living there would be reset by machinery that
|
|
// knows nothing about stats — fine today, and exactly the kind of coupling that breaks quietly the
|
|
// first time the reset order changes. endRun() is the only thing that empties this bag.
|
|
|
|
import { WEAPONS, ENEMIES } from './config.js';
|
|
|
|
const KEY = 'splatterRuns';
|
|
// ponytail: the array is capped, so "ALL TIME" means "your last CAP runs". At ~170 bytes a record
|
|
// that is ~50 KB against a 5 MB quota, and 300 deaths is months of play. If anyone ever passes it,
|
|
// the upgrade is a second key holding rolled-up lifetime totals that the cap never touches — not a
|
|
// bigger cap, which just moves the cliff.
|
|
const CAP = 300;
|
|
|
|
// A run in progress. `z` is kills by enemy row key, `w` is kills by weapon id.
|
|
const bag = () => ({ k: 0, mk: 0, z: {}, w: {} });
|
|
let run = bag();
|
|
// Has the CURRENT run already been written? An emptied bag is not enough to answer that: the games
|
|
// count lives on G and survives the flush, so a second endRun would sail past a "nothing happened"
|
|
// guard and bank a duplicate with zero kills. `pagehide` really does fire more than once — the
|
|
// bfcache dismisses and restores the same page — which is exactly how this was caught.
|
|
// Cleared by the next kill — which was the whole of the reset until CLASSIC TENNIS arrived, a mode
|
|
// with NO KILLS IN IT AT ALL. There, nothing ever cleared the flag, so the second and every later
|
|
// match of a session was silently swallowed by the guard below. beginRun() is the honest reset and
|
|
// gameplay.restartRun (the one "a new run starts here" moment there is) calls it.
|
|
let banked = false;
|
|
export function beginRun() { run = bag(); banked = false; }
|
|
|
|
// ---------------------------------------------------------------- storage ----
|
|
// Private-mode Safari throws on both read and write. Stats are a nicety; a menu that will not open
|
|
// is not. Every path through this file degrades to "no stats" and never to an exception.
|
|
function load() {
|
|
try {
|
|
const a = JSON.parse(localStorage.getItem(KEY) || 'null');
|
|
if (Array.isArray(a)) return a;
|
|
return migrate();
|
|
} catch { return []; }
|
|
}
|
|
|
|
// The only number the game used to keep was localStorage.splatterBest = {games, sets}, written
|
|
// inside killPlayer. Rather than orphan it, it comes across as a single synthetic run the first
|
|
// time this module looks — so an existing player's best survives the upgrade instead of resetting
|
|
// to zero on the device they actually play on.
|
|
function migrate() {
|
|
try {
|
|
const b = JSON.parse(localStorage.splatterBest || 'null');
|
|
if (!b || !b.games) return [];
|
|
const seed = [{ t: Date.now(), g: b.games, s: b.sets || 0, d: 0, k: 0, mk: 0, dead: 1, z: {}, w: {} }];
|
|
localStorage.setItem(KEY, JSON.stringify(seed));
|
|
localStorage.removeItem('splatterBest'); // one source of truth from here on
|
|
return seed;
|
|
} catch { return []; }
|
|
}
|
|
|
|
const save = a => { try { localStorage.setItem(KEY, JSON.stringify(a.slice(-CAP))); } catch {} };
|
|
|
|
// ------------------------------------------------------------- collection ----
|
|
// One kill. `variant` is the zombie's own char; 'a' and 'b' are one shambler in two shirts and fold
|
|
// together, and a chapter boss arrives already keyed 'B0'..'B5' by design (see zombies.killZombie).
|
|
export function tally(variant, weapon) {
|
|
const z = variant === 'b' ? 'a' : variant;
|
|
banked = false; // something happened: this is a live run again
|
|
run.k++;
|
|
run.z[z] = (run.z[z] || 0) + 1;
|
|
run.w[weapon] = (run.w[weapon] || 0) + 1;
|
|
}
|
|
|
|
// A shot that took two or more at once — counted ONCE per shot, on the transition to 2, so a
|
|
// massacre of six is one multi-kill and not five.
|
|
export function multiKill() { run.mk++; }
|
|
|
|
// The run is over: fold it into the record and hand back the best run so far. `dead` separates a
|
|
// death from a tab closed mid-match — both are worth recording, only one is a death.
|
|
// Idempotent: a run with nothing in it writes nothing, so death-then-close cannot double-count and
|
|
// closing the tab at the menu does not invent an empty match.
|
|
//
|
|
// `g` AND `s` ARE THE MODE'S OWN TWO HEADLINE NUMBERS, not literally games and sets:
|
|
// gore — g = games survived, s = sets banked (what it has always been)
|
|
// match — g = OPPONENTS FELLED, s = games played
|
|
// `g` is the one everything ranks on (best, newBest, `longest`), so putting each mode's real
|
|
// measure in that slot is what lets one record shape serve both without a second best written
|
|
// somewhere else and left to drift — which is the mistake splatterBest already made once.
|
|
// WHICH MODE WROTE THIS RUN. One character on the record, because the two modes do not share a
|
|
// single currency: the gore mode counts kills, multi-kills and weapons; zombie tennis counts
|
|
// opponents felled and has exactly one kill per opponent, by definition, always credited to 'bomb'
|
|
// (matchGameWon's explode). Un-tagged records predate the split and ARE gore runs, which is why the
|
|
// default is 'g' everywhere and no migration is needed.
|
|
export const MODE_GORE = 'g', MODE_MATCH = 'z', MODE_CLASSIC = 'c';
|
|
const modeOf = r => r.m || MODE_GORE;
|
|
|
|
export function endRun(games, sets, secs, dead = true, mode = MODE_GORE) {
|
|
const all = load();
|
|
const prev = all.filter(r => modeOf(r) === mode); // you are only ever compared against your own mode
|
|
const best = prev.reduce((b, r) => r.g > b.games ? { games: r.g, sets: r.s } : b, { games: 0, sets: 0 });
|
|
// TWO bests, because they answer different questions. `newBest` is GAMES and belongs to the death
|
|
// summary, which has always celebrated how long you lasted. `newKillBest` is KILLS, which is what
|
|
// the world board ranks — so it is the one that earns you a name-entry prompt.
|
|
const bestKills = prev.reduce((m, r) => Math.max(m, r.k || 0), 0);
|
|
const kills = run.k;
|
|
if (banked || (!run.k && !games)) { run = bag(); return { ...best, newBest: false, newKillBest: false }; }
|
|
const newBest = games > best.games;
|
|
// THE WORLD BOARD IS THE GORE MODE'S. It ranks kills in a run, and a match run has exactly one
|
|
// kill per opponent felled — a five-body match would post a "5 kills" row next to a 300-kill
|
|
// massacre and mean something completely different by it. No name prompt from a match.
|
|
// ponytail: when zombie tennis earns a board of its own it ranks FELLED (with points won as the
|
|
// tiebreak — felled alone is a small integer and would be a wall of ties), and that is a server
|
|
// change, not this line.
|
|
const newKillBest = mode === MODE_GORE && kills > bestKills;
|
|
all.push({ t: Date.now(), g: games, s: sets, d: Math.round(secs || 0), m: mode,
|
|
k: kills, mk: run.mk, dead: dead ? 1 : 0, z: run.z, w: run.w });
|
|
save(all); // ...the WHOLE record, not the filtered view — that would delete the other mode
|
|
run = bag(); banked = true;
|
|
last = { kills, games, newKillBest };
|
|
// `games`/`sets` here are THE BEST RUN'S, which is the only thing a caller wants back — the
|
|
// caller already knows what the run it just handed over did. They used to be trailed by the
|
|
// ENDED run's own `kills, games`, and the later key won: `G.best.games` was the run that had just
|
|
// died, so the death screen printed "BEST 3 GAMES · 7 SETS" — this run's games, the record's sets.
|
|
return { ...best, newBest, newKillBest };
|
|
}
|
|
|
|
// ------------------------------------------------------------- aggregation ----
|
|
// `runs` is injectable so check() can assert against a fixture without touching localStorage.
|
|
export function read(period, runs = load(), mode = MODE_GORE) {
|
|
const own = runs.filter(r => modeOf(r) === mode); // ...and only ever one mode's runs at a time
|
|
const rs = period === 'last' ? own.slice(-1)
|
|
: period === '7d' ? own.filter(r => Date.now() - r.t < 7 * 864e5)
|
|
: own;
|
|
const out = { kills: 0, multi: 0, deaths: 0, longest: 0, matches: rs.length, z: {}, w: {} };
|
|
for (const r of rs) {
|
|
out.kills += r.k || 0; out.multi += r.mk || 0; out.deaths += r.dead || 0;
|
|
out.longest = Math.max(out.longest, r.g || 0);
|
|
for (const k in r.z) out.z[k] = (out.z[k] || 0) + r.z[k];
|
|
for (const k in r.w) out.w[k] = (out.w[k] || 0) + r.w[k];
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// the two figures the splash carries
|
|
export function total() {
|
|
const a = read('all');
|
|
return { kills: a.kills, best: a.longest };
|
|
}
|
|
|
|
// ------------------------------------------------------------ leaderboard ----
|
|
// The world board, ranked on KILLS IN A SINGLE RUN — the game's own currency, and fine-grained
|
|
// enough that the order means something (games survived is a small integer and would be a wall of
|
|
// ties). Games ride along as the tiebreak and as a column.
|
|
// Two routes, no auth. See server.py: the board is a wall for writing your name on.
|
|
const NAME_KEY = 'splatterName', ID_KEY = 'splatterId';
|
|
export const savedName = () => { try { return localStorage.getItem(NAME_KEY) || ''; } catch { return ''; } };
|
|
|
|
// Your claim on your name. Minted once, never shown, never leaves this device except to prove a
|
|
// submission is yours — see server.py. It is not a login and it protects nothing but the row: lose
|
|
// your browser storage and you lose the name, which is what having no accounts costs.
|
|
function myId() {
|
|
try {
|
|
let v = localStorage.getItem(ID_KEY);
|
|
if (!v) { v = Array.from(crypto.getRandomValues(new Uint8Array(12)))
|
|
.map(b => b.toString(36).padStart(2, '0')).join(''); localStorage.setItem(ID_KEY, v); }
|
|
return v;
|
|
} catch { return ''; } // private mode: no claim, and the first stranger to use the name keeps it
|
|
}
|
|
|
|
// The run just flushed, held here rather than on G — G is restored from PRISTINE by restartRun(),
|
|
// and the name prompt has to survive exactly that. Consumed on read so it prompts once.
|
|
let last = null;
|
|
export function lastRun() { const r = last; last = null; return r; }
|
|
|
|
export async function board() {
|
|
const r = await fetch('api/scores', { cache: 'no-store' });
|
|
if (!r.ok) throw new Error('board ' + r.status);
|
|
return (await r.json()).scores || [];
|
|
}
|
|
|
|
// The SAME normalisation server.py's clean() applies. Both ends have to agree or nothing lines up:
|
|
// the server stored OLIVER, the client remembered "oliver", and the board then failed to highlight
|
|
// the row you had just written. Normalising here also means the prefill shows what will really
|
|
// appear on the board.
|
|
export const tidyName = s =>
|
|
String(s).toUpperCase().replace(/[^A-Z0-9 ._-]/g, '').trim().slice(0, 12);
|
|
|
|
export async function submit(rawName, kills, games) {
|
|
const name = tidyName(rawName);
|
|
if (!name) throw new Error('empty name');
|
|
try { localStorage.setItem(NAME_KEY, name); } catch {} // type it once, ever
|
|
const r = await fetch('api/scores', { method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ n: name, k: kills, g: games, id: myId() }) });
|
|
if (r.status === 409) { const e = new Error('name taken'); e.taken = true; throw e; }
|
|
if (!r.ok) throw new Error('submit ' + r.status);
|
|
return r.json(); // {ok, rank, scores}
|
|
}
|
|
|
|
const n = x => x.toLocaleString('en-GB');
|
|
// Names come off the wire and are written by other people. server.py whitelists them down to
|
|
// [A-Z0-9 ._-] so nothing dangerous can survive the round trip — this is the second lock on the
|
|
// same door, because the alternative is trusting a remote string into innerHTML.
|
|
const esc = s => String(s).replace(/[&<>"']/g, c =>
|
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
const wIcon = id => id === 'ball' ? 'w_ball' : WEAPONS[id] ? WEAPONS[id].icon : null;
|
|
|
|
// ----------------------------------------------------------------- screens ----
|
|
// Both screens are built here rather than in main.js: they are the only readers of the shapes
|
|
// above, and main.js is the boot file, not a view layer.
|
|
// ONE SCREEN, THREE MODES. The three do not share a currency — see the record-shape note in
|
|
// endRun — so they do not share a layout either: gore counts bodies and weapons, zombie tennis
|
|
// counts NAMED opponents felled, classic tennis counts matches won and lost. What they do share is
|
|
// the furniture (the period tabs, the figure strip, the roster list, the footer), so this is one
|
|
// function with three sets of words rather than three screens to keep in step.
|
|
const roster = (el, rows, v, empty) => {
|
|
const top = rows.length ? rows[0][1] : 1;
|
|
el.innerHTML = rows.length ? rows.map(([k, c]) => {
|
|
// a key with no row is a save written by an older build: name it off the key, stripped, because
|
|
// this is the one string on the screen that does not come from our own tables
|
|
const e = ENEMIES[k] || { name: k.replace(/[^A-Za-z0-9]/g, '').toUpperCase() || '?', spr: 'z_a1', sub: '' };
|
|
return `<div class="zrow${e.boss ? ' boss' : ''}">
|
|
<img src="assets/${e.spr}.png?v=${v}" alt="">
|
|
<div class="zb"><b>${e.name}</b><span>${n(c)}</span>
|
|
<div class="pb"><s style="width:${Math.max(3, c / top * 100)}%"></s></div>
|
|
<i>${e.sub}</i></div></div>`;
|
|
}).join('') : `<p class="none">${empty}</p>`;
|
|
};
|
|
|
|
export function drawStats(period, v, mode = MODE_GORE) {
|
|
const d = read(period, undefined, mode);
|
|
const $ = id => document.getElementById(id);
|
|
$('stPeriod').textContent = period === 'last' ? 'LAST RUN' : period === '7d' ? '7 DAYS' : 'ALL TIME';
|
|
$('stTitle').textContent = mode === MODE_MATCH ? 'THE LADDER'
|
|
: mode === MODE_CLASSIC ? 'THE RECORD' : 'BODY COUNT';
|
|
const figs = mode === MODE_MATCH
|
|
? [['FELLED', n(d.kills)], ['DEEPEST', n(d.longest)], ['RUNS', n(d.matches)]]
|
|
: mode === MODE_CLASSIC
|
|
? [['PLAYED', n(d.matches)], ['WON', n(d.matches - d.deaths)], ['LOST', n(d.deaths)]]
|
|
: [['KILLS', n(d.kills)], ['MULTI-KILLS', n(d.multi)], ['DEATHS', n(d.deaths)]];
|
|
// the third figure is the RED one in gore and in classic (deaths, matches lost); in the match it
|
|
// is just how many runs you have played, so nothing on that strip is bad news
|
|
const badIdx = mode === MODE_MATCH ? -1 : 2;
|
|
$('stFigs').innerHTML = figs.map(([k, val], i) =>
|
|
`<div class="fig${i === badIdx ? ' bad' : ''}"><b>${k}</b><i>${val}</i></div>`).join('');
|
|
|
|
// sorted by count, and only bodies you have actually met — an empty roster of everything would
|
|
// read as a checklist of things to go and do, which this screen is not
|
|
const rows = Object.entries(d.z).filter(([, c]) => c > 0).sort((a, b) => b[1] - a[1]);
|
|
$('stZHdr').firstChild.textContent = mode === MODE_MATCH ? 'OPPONENTS FELLED' : 'BY OPPONENT';
|
|
roster($('stZ'), rows, v, mode === MODE_MATCH ? 'NOBODY HAS GONE DOWN YET.' : 'NOTHING YET. GO AND EARN A ROW.');
|
|
|
|
// THE WEAPON SHELF IS THE GORE MODE'S ALONE. Both tennis modes are played with plain balls (the
|
|
// rack is hidden in both), so the section is hidden rather than printed empty — a shelf that says
|
|
// NO WEAPONS FIRED for a mode that has none reads as something you failed to do.
|
|
const ws = Object.entries(d.w).filter(([id, c]) => c > 0 && wIcon(id)).sort((a, b) => b[1] - a[1]);
|
|
// style.display, not the `hidden` attribute: #stW carries a display rule of its own and any
|
|
// declared display beats [hidden]'s, so the shelf stayed on screen under a mode with no weapons.
|
|
const off = mode !== MODE_GORE ? 'none' : '';
|
|
$('stWHdr').style.display = off; $('stW').style.display = off;
|
|
// ...and CLASSIC TENNIS has no roster either: nothing dies in it. A section printing that is a
|
|
// line about something the mode does not do, which is worse than the section not being there.
|
|
const noZ = mode === MODE_CLASSIC ? 'none' : '';
|
|
$('stZHdr').style.display = noZ; $('stZ').style.display = noZ;
|
|
$('stW').innerHTML = ws.length ? ws.map(([id, c]) =>
|
|
`<div class="wtile"><img src="assets/${wIcon(id)}.png?v=${v}" alt=""><b>${n(c)}</b></div>`).join('')
|
|
: '<p class="none">NO WEAPONS FIRED.</p>';
|
|
|
|
$('stFoot').innerHTML = mode === MODE_MATCH
|
|
? `<b>DEEPEST RUN</b><i>${d.longest} FELLED</i>`
|
|
: mode === MODE_CLASSIC
|
|
? `<b>LONGEST MATCH</b><i>${d.longest} GAME${d.longest === 1 ? '' : 'S'}</i>`
|
|
: `<b>LONGEST RUN</b><i>${d.longest} GAME${d.longest === 1 ? '' : 'S'}</i>`;
|
|
}
|
|
|
|
// The real board. Async, and every failure lands on a line of copy rather than an empty screen —
|
|
// the server being down must never be indistinguishable from nobody having played.
|
|
export async function drawRanks(rows) {
|
|
const el = document.getElementById('rkList');
|
|
if (!rows) {
|
|
el.innerHTML = '<p class="none">READING THE BOARD…</p>';
|
|
try { rows = await board(); }
|
|
catch { el.innerHTML = '<p class="none">THE BOARD IS UNREACHABLE.</p>'; return; }
|
|
}
|
|
if (!rows.length) { el.innerHTML = '<p class="none">NOBODY IS ON THE BOARD YET. BE FIRST.</p>'; return; }
|
|
const me = savedName();
|
|
el.innerHTML = rows.map((r, i) =>
|
|
`<div class="rkrow${r.n === me ? ' me' : ''}"><b>${n(i + 1)}</b><span>${esc(r.n)}</span>`
|
|
+ `<i>${n(r.k || 0)} · ${r.g || 0}G</i></div>`).join('');
|
|
}
|
|
|
|
// ------------------------------------------------------------------ check ----
|
|
// The one runnable check: the period filter and the aggregation, against a fixture. `read` takes an
|
|
// injected array precisely so this needs no localStorage and cannot disturb a real save.
|
|
export function check() {
|
|
const day = 864e5, now = Date.now();
|
|
const fx = [
|
|
{ t: now - 9 * day, g: 3, s: 0, d: 60, k: 10, mk: 1, dead: 1, z: { a: 8, g: 2 }, w: { ball: 10 } },
|
|
{ t: now - 2 * day, g: 7, s: 1, d: 90, k: 5, mk: 0, dead: 1, z: { g: 5 }, w: { bomb: 5 } },
|
|
{ t: now - 1000, g: 1, s: 0, d: 10, k: 1, mk: 2, dead: 0, z: { a: 1 }, w: { ball: 1 } },
|
|
// a zombie-tennis run sitting in the same array: 4 opponents felled over 9 games. Its four
|
|
// kills are 'r' bodies credited to the game-winning blast and must never reach a gore figure.
|
|
{ t: now - 500, g: 4, s: 9, d: 40, m: 'z', k: 4, mk: 0, dead: 1, z: { r: 4 }, w: { bomb: 4 } },
|
|
];
|
|
const all = read('all', fx), wk = read('7d', fx), last = read('last', fx);
|
|
const eq = (got, want, what) => { if (got !== want) throw new Error(`${what}: ${got} !== ${want}`); };
|
|
// THE MODE SPLIT. Un-tagged records are gore runs (that is what they were before the tag), so the
|
|
// three above still answer every gore question exactly as they did — and the match run answers none
|
|
// of them. 'last' is the last run OF THAT MODE, not the last run overall.
|
|
const mz = read('all', fx, 'z');
|
|
eq(all.matches, 3, 'gore sees only gore runs');
|
|
eq(all.z.r || 0, 0, 'a felled opponent is not a shambler on the gore roster');
|
|
eq(mz.matches, 1, 'and the match view sees only its own');
|
|
eq(mz.longest, 4, 'the match measure is OPPONENTS FELLED, in the record`s ranking slot');
|
|
eq(read('last', fx, 'z').kills, 4, 'last-of-mode, not last-overall');
|
|
eq(all.kills, 16, 'all kills');
|
|
eq(all.deaths, 2, 'all deaths'); // the abandoned run is recorded but is not a death
|
|
eq(all.longest, 7, 'all longest');
|
|
eq(all.z.a, 9, 'all shamblers');
|
|
eq(wk.kills, 6, '7d kills'); // the 9-day-old run is outside the window
|
|
eq(wk.z.a, 1, '7d shamblers');
|
|
eq(last.kills, 1, 'last kills');
|
|
eq(last.multi, 2, 'last multi');
|
|
eq(Object.entries(all.z).sort((a, b) => b[1] - a[1])[0][0], 'a', 'sort');
|
|
// tidyName has to agree with server.py's clean() or the board cannot highlight your own row —
|
|
// it is the one rule duplicated across two languages, so it is the one worth pinning down.
|
|
eq(tidyName(' oliver '), 'OLIVER', 'name upper+trim');
|
|
eq(tidyName('<b>hi</b>'), 'BHIB', 'name strips markup');
|
|
eq(tidyName('ABCDEFGHIJKLMNOPQRSTUVWXYZ').length, 12, 'name capped at 12');
|
|
eq(tidyName('...'), '...', 'name keeps the allowed punctuation');
|
|
return 'ok';
|
|
}
|