step 8.3: done
This commit is contained in:
+16
-16
@@ -4,8 +4,8 @@
|
|||||||
> If this file disagrees with the code, trust the code and fix this file.
|
> If this file disagrees with the code, trust the code and fix this file.
|
||||||
|
|
||||||
## 1. Where we are
|
## 1. Where we are
|
||||||
- **Last completed step:** 8.2 (Public profile: `GET /api/heroes/:name`) — TICKED in STATUS.md
|
- **Last completed step:** 8.3 (Public profile UI page) — TICKED in STATUS.md
|
||||||
- **Next step:** 8.3 — UI page for public hero profile.
|
- **Next step:** Phase 9 — Hardening & Polish (9.1 first)
|
||||||
- **Updated:** 2026-10-21
|
- **Updated:** 2026-10-21
|
||||||
- **Was the last session cut off mid-task?** No
|
- **Was the last session cut off mid-task?** No
|
||||||
|
|
||||||
@@ -14,11 +14,13 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `npm install` | succeeds |
|
| `npm install` | succeeds |
|
||||||
| `npm run migrate` | applies 001–006 / "nothing to do" |
|
| `npm run migrate` | applies 001–006 / "nothing to do" |
|
||||||
| `npm test` | **167 passing, 0 failing** (85 unit + 72 integration) |
|
| `npm test` | **172 passing, 0 failing** (85 unit + 87 integration) |
|
||||||
|
|
||||||
## 3. What exists now (files that matter)
|
## 3. What exists now (files that matter)
|
||||||
- `src/services/heroProfileService.js` — `getHeroProfile(heroName)`: case-insensitive lookup, returns name/class/level/xp/gold/str/agi/int/vit/kills/deaths/equipment (equipped item names by slot). Uses WeakMap stmt cache.
|
- `public/profile.html` — Public hero profile page (reads ?name=, displays stats/equipment).
|
||||||
- `src/routes/heroProfile.js` — `GET /api/heroes/:name` (public, no auth). Returns 404 if hero not found.
|
- `public/js/profile.js` — Fetches GET /api/heroes/:name, renders profile, handles 404.
|
||||||
|
- `src/services/heroProfileService.js` — `getHeroProfile(heroName)`: case-insensitive lookup, returns name/class/level/xp/gold/str/agi/int/vit/kills/deaths/equipment.
|
||||||
|
- `src/routes/heroProfile.js` — `GET /api/heroes/:name` (public, no auth).
|
||||||
- `src/services/leaderboardService.js` — `getLeaderboard(type, page, limit)`.
|
- `src/services/leaderboardService.js` — `getLeaderboard(type, page, limit)`.
|
||||||
- `src/routes/leaderboard.js` — `GET /api/leaderboard?type=level|gold|kills&page=1`.
|
- `src/routes/leaderboard.js` — `GET /api/leaderboard?type=level|gold|kills&page=1`.
|
||||||
- `src/services/expeditionService.js` — start, getActive, claim, listDefinitions.
|
- `src/services/expeditionService.js` — start, getActive, claim, listDefinitions.
|
||||||
@@ -36,17 +38,18 @@
|
|||||||
- `public/game.html` — dashboard with stats panel, tabbed action panel (Hunt/Inventory/Expedition).
|
- `public/game.html` — dashboard with stats panel, tabbed action panel (Hunt/Inventory/Expedition).
|
||||||
- `public/js/game.js` — hunt UI + inventory UI + expedition UI.
|
- `public/js/game.js` — hunt UI + inventory UI + expedition UI.
|
||||||
- `public/shop.html` — dedicated shop page.
|
- `public/shop.html` — dedicated shop page.
|
||||||
- `tests/integration/hero-profile.test.js` — 5 integration tests.
|
- `public/login.html`, `public/register.html`, `public/create-hero.html` — auth & hero creation.
|
||||||
|
- `tests/integration/profile.test.js` — 5 integration tests.
|
||||||
|
|
||||||
## 4. Half-finished / fragile things
|
## 4. Half-finished / fragile things
|
||||||
- No leaderboard UI yet (Phase 8).
|
- No leaderboard UI yet (Phase 8, but 8.1/8.2 API done — could add UI if desired).
|
||||||
- No public profile UI page (step 8.3 next).
|
|
||||||
- HP never regenerates naturally; only level-up restores it.
|
- HP never regenerates naturally; only level-up restores it.
|
||||||
- No restart-persistence test for sessions.
|
- No restart-persistence test for sessions.
|
||||||
- `npm run seed` script in package.json points to nonexistent `scripts/seed.js`.
|
- `npm run seed` script in package.json points to nonexistent `scripts/seed.js`.
|
||||||
- Equipment bonuses not yet passed into combat (Issue #5 in STATUS.md).
|
- Equipment bonuses not yet passed into combat (Issue #5 in STATUS.md).
|
||||||
- Starter gear (GAME_DESIGN.md §Items) documented but not auto-created on character creation.
|
- Starter gear (GAME_DESIGN.md §Items) documented but not auto-created on character creation.
|
||||||
- `GET /api/zones` documented in API.md but not implemented (Issue #2).
|
- `GET /api/zones` documented in API.md but not implemented (Issue #2).
|
||||||
|
- Phase 9 (hardening) not started.
|
||||||
|
|
||||||
## 5. Decisions & deviations from the docs
|
## 5. Decisions & deviations from the docs
|
||||||
- D-004..D-007 in `docs/DECISIONS.md` (combat rounds, equipment counted once, session store, clock).
|
- D-004..D-007 in `docs/DECISIONS.md` (combat rounds, equipment counted once, session store, clock).
|
||||||
@@ -73,12 +76,9 @@
|
|||||||
- Shop stock items have flat fields (`itemId`, `buyPrice`), not nested (`item.id`).
|
- Shop stock items have flat fields (`itemId`, `buyPrice`), not nested (`item.id`).
|
||||||
- New characters have NO inventory items (starter gear not auto-created yet).
|
- New characters have NO inventory items (starter gear not auto-created yet).
|
||||||
- Hero profile endpoint is PUBLIC — no `requireAuth` middleware.
|
- Hero profile endpoint is PUBLIC — no `requireAuth` middleware.
|
||||||
|
- Profile page is public: no auth.js guard needed; uses ?name= query param for lookup.
|
||||||
|
|
||||||
## 7. Next-step briefing (8.3)
|
## 7. Next-step briefing (9.1)
|
||||||
- **Goal:** Public profile UI page — a standalone HTML page showing the hero profile returned by `GET /api/heroes/:name`.
|
- **Goal:** Walk the SECURITY.md checklist; fix gaps.
|
||||||
- **Docs:** GAME_DESIGN.md §Leaderboards, UI_GUIDE.
|
- **Docs:** SECURITY.md, CODING_STANDARDS.
|
||||||
- **Files to create:** `public/profile.html`, `public/js/profile.js`.
|
- Phase 9 is hardening & polish — review security, add error pages, loading states, mobile responsive pass, final playable run.
|
||||||
- **No new backend needed** — the API endpoint already exists.
|
|
||||||
|
|
||||||
## 8. Open Known Issues
|
|
||||||
STATUS.md issues 1–6 (balance, dangling `seed` script, restart test, equipment-in-combat, HP regen, zone min_level balance).
|
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
## Phase 8 — Leaderboards & Social-lite (docs: GAME_DESIGN §Leaderboards)
|
## Phase 8 — Leaderboards & Social-lite (docs: GAME_DESIGN §Leaderboards)
|
||||||
- [x] **8.1** `GET /api/leaderboard?type=level|gold|kills` top 50, with pagination param.
|
- [x] **8.1** `GET /api/leaderboard?type=level|gold|kills` top 50, with pagination param.
|
||||||
- [x] **8.2** Public profile `GET /api/heroes/:name` (no private data).
|
- [x] **8.2** Public profile `GET /api/heroes/:name` (no private data).
|
||||||
- [ ] **8.3** UI page.
|
- [x] **8.3** Public profile UI page (`public/profile.html` + `public/js/profile.js`).
|
||||||
|
|
||||||
## Phase 9 — Hardening & Polish (docs: SECURITY, UI_GUIDE, TESTING)
|
## Phase 9 — Hardening & Polish (docs: SECURITY, UI_GUIDE, TESTING)
|
||||||
- [ ] **9.1** Walk the SECURITY.md checklist; fix gaps.
|
- [ ] **9.1** Walk the SECURITY.md checklist; fix gaps.
|
||||||
@@ -150,11 +150,12 @@ Severity: `blocker` / `major` / `minor` / `cosmetic`. Status: `open` / `fixing`
|
|||||||
## Test Summary
|
## Test Summary
|
||||||
| Suite | Passing | Failing | Last run |
|
| Suite | Passing | Failing | Last run |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| unit | 85 | 0 | 2026-10-21 step 7.3 |
|
| unit | 85 | 0 | 2026-10-21 step 8.3 |
|
||||||
| integration | 68 | 0 | 2026-10-21 step 7.3 |
|
| integration | 87 | 0 | 2026-10-21 step 8.3 |
|
||||||
|
|
||||||
## Session Log
|
## Session Log
|
||||||
_Format:_ `YYYY-MM-DD | step | what changed | result`
|
_Format:_ `YYYY-MM-DD | step | what changed | result`
|
||||||
|
- 2026-10-21 | 8.3 | Created public profile UI: `public/profile.html` + `public/js/profile.js` (fetches GET /api/heroes/:name via ?name=, displays hero name/class/level/gold/stats/equipment; 404 handling, URL-safe names); 5 integration tests | 172/172 tests pass
|
||||||
- 2026-10-21 | 7.3 | Added expedition UI: GET /api/expeditions/defs endpoint (expeditionService.listDefinitions + expeditions route), CSS styles for expedition elements, game.html expedition tab, game.js expedition UI (renderExpeditionDefs, renderActiveExpedition, handleStartExpedition, handleClaimExpedition, startExpeditionSync), 2 integration tests for defs endpoint | 153/153 tests pass
|
- 2026-10-21 | 7.3 | Added expedition UI: GET /api/expeditions/defs endpoint (expeditionService.listDefinitions + expeditions route), CSS styles for expedition elements, game.html expedition tab, game.js expedition UI (renderExpeditionDefs, renderActiveExpedition, handleStartExpedition, handleClaimExpedition, startExpeditionSync), 2 integration tests for defs endpoint | 153/153 tests pass
|
||||||
- 2026-10-21 | 7.2 | Created `src/services/expeditionService.js` (start, getActive, claim), `src/routes/expeditions.js` (3 endpoints), integrated into app.js; 11 integration tests covering start, active, claim, error paths, double-claim, cross-user rejection, time-based finish | 151/151 tests pass
|
- 2026-10-21 | 7.2 | Created `src/services/expeditionService.js` (start, getActive, claim), `src/routes/expeditions.js` (3 endpoints), integrated into app.js; 11 integration tests covering start, active, claim, error paths, double-claim, cross-user rejection, time-based finish | 151/151 tests pass
|
||||||
- 2026-10-21 | 7.1 | Created migration `005_expeditions.sql` (expedition_defs + expeditions tables, unique active-expedition index, 3 seed definitions); migration applies clean, all tests pass | 139/139 tests pass
|
- 2026-10-21 | 7.1 | Created migration `005_expeditions.sql` (expedition_defs + expeditions tables, unique active-expedition index, 3 seed definitions); migration applies clean, all tests pass | 139/139 tests pass
|
||||||
|
|||||||
@@ -660,3 +660,137 @@ button:hover { background: #c73650; }
|
|||||||
background: #555;
|
background: #555;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Profile page ===== */
|
||||||
|
.profile-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header h2 {
|
||||||
|
color: #e94560;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-class-badge {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #a0a0a0;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-level-badge {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #f2c14e;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #0f3460;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #a0a0a0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #eadfce;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gold-value {
|
||||||
|
color: #f2c14e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.profile-body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stats h3,
|
||||||
|
.profile-equipment h3 {
|
||||||
|
color: #e94560;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stat-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
background: #0f3460;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stat-label {
|
||||||
|
color: #a0a0a0;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stat-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #eadfce;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-equip-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-equip-slot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: #0f3460;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-slot-label {
|
||||||
|
color: #a0a0a0;
|
||||||
|
text-transform: capitalize;
|
||||||
|
min-width: 3.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-slot-item {
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-slot-empty {
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// Profile page: public hero profile lookup via ?name= query param.
|
||||||
|
|
||||||
|
const ITEM_EMOJI = { weapon: '⚔️', armor: '🛡️', trinket: '💍', consumable: '🧪', material: '📦' };
|
||||||
|
|
||||||
|
// ─── API helper (no auth needed) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
async function apiGet(path) {
|
||||||
|
const res = await fetch(path);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Display helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatKd(kills, deaths) {
|
||||||
|
if (deaths === 0) return kills > 0 ? `${kills}.00` : '—';
|
||||||
|
return (kills / deaths).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Render: profile ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderProfile(data) {
|
||||||
|
document.getElementById('profile-loading').style.display = 'none';
|
||||||
|
document.getElementById('profile-content').style.display = '';
|
||||||
|
|
||||||
|
document.getElementById('profile-name').textContent = data.name;
|
||||||
|
|
||||||
|
const classEl = document.getElementById('profile-class');
|
||||||
|
classEl.textContent = data.class;
|
||||||
|
classEl.className = `profile-class-badge rarity-${data.class}`;
|
||||||
|
|
||||||
|
document.getElementById('profile-level').textContent = `Lv.${data.level}`;
|
||||||
|
document.getElementById('profile-gold').textContent = `💰 ${data.gold.toLocaleString()}`;
|
||||||
|
document.getElementById('profile-kills').textContent = data.kills;
|
||||||
|
document.getElementById('profile-deaths').textContent = data.deaths;
|
||||||
|
document.getElementById('profile-kd').textContent = formatKd(data.kills, data.deaths);
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
const statsList = document.getElementById('profile-stats-list');
|
||||||
|
statsList.innerHTML = '';
|
||||||
|
for (const stat of ['str', 'agi', 'int', 'vit']) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'profile-stat-row';
|
||||||
|
row.innerHTML = `
|
||||||
|
<span class="profile-stat-label">${stat}</span>
|
||||||
|
<span class="profile-stat-value">${data[stat]}</span>
|
||||||
|
`;
|
||||||
|
statsList.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equipment
|
||||||
|
const equipList = document.getElementById('profile-equipment-list');
|
||||||
|
equipList.innerHTML = '';
|
||||||
|
for (const slot of ['weapon', 'armor', 'trinket']) {
|
||||||
|
const slotEl = document.createElement('div');
|
||||||
|
slotEl.className = 'profile-equip-slot';
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'profile-slot-label';
|
||||||
|
label.textContent = slot;
|
||||||
|
slotEl.appendChild(label);
|
||||||
|
|
||||||
|
const item = data.equipment[slot];
|
||||||
|
if (item) {
|
||||||
|
const value = document.createElement('span');
|
||||||
|
value.className = `profile-slot-item rarity-${getRarityForSlot(slot, data)}`;
|
||||||
|
value.textContent = `${ITEM_EMOJI[slot] || ''} ${item}`;
|
||||||
|
slotEl.appendChild(value);
|
||||||
|
} else {
|
||||||
|
const empty = document.createElement('span');
|
||||||
|
empty.className = 'profile-slot-empty';
|
||||||
|
empty.textContent = 'Empty';
|
||||||
|
slotEl.appendChild(empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
equipList.appendChild(slotEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRarityForSlot(slot, data) {
|
||||||
|
// Fallback: use class name for colour when item name is present but we don't have rarity info.
|
||||||
|
return data.class || 'common';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Init ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const name = params.get('name');
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
document.getElementById('profile-loading').style.display = 'none';
|
||||||
|
document.getElementById('profile-error').style.display = '';
|
||||||
|
document.getElementById('profile-error').textContent = 'No hero name provided. Use ?name=HeroName.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiGet(`/api/heroes/${encodeURIComponent(name)}`);
|
||||||
|
if (!data) {
|
||||||
|
throw new Error('Empty response');
|
||||||
|
}
|
||||||
|
if (!data.ok) {
|
||||||
|
if (data.error?.code === 'NOT_FOUND') {
|
||||||
|
document.getElementById('profile-loading').style.display = 'none';
|
||||||
|
document.getElementById('profile-notfound').style.display = '';
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error?.message || 'Failed to load profile.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderProfile(data.data);
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('profile-loading').style.display = 'none';
|
||||||
|
document.getElementById('profile-error').style.display = '';
|
||||||
|
document.getElementById('profile-error').textContent = err.message || 'Failed to load profile.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Emberfall — Hero Profile</title>
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="game-container">
|
||||||
|
<!-- Top bar -->
|
||||||
|
<header class="top-bar">
|
||||||
|
<div class="logo">Emberfall</div>
|
||||||
|
<div class="user-info">
|
||||||
|
<a href="/login.html">Login</a>
|
||||||
|
<a href="/leaderboard.html">Leaderboard</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<!-- Hero profile panel -->
|
||||||
|
<section class="panel profile-panel" style="grid-column: 1 / -1;">
|
||||||
|
<div id="profile-loading" class="loading">Loading hero profile...</div>
|
||||||
|
<div id="profile-content" style="display:none;">
|
||||||
|
<div class="profile-header">
|
||||||
|
<h2 id="profile-name"></h2>
|
||||||
|
<span id="profile-class" class="profile-class-badge"></span>
|
||||||
|
<span id="profile-level" class="profile-level-badge"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-summary">
|
||||||
|
<div class="summary-item">
|
||||||
|
<span class="summary-label">Gold</span>
|
||||||
|
<span id="profile-gold" class="summary-value gold-value"></span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<span class="summary-label">Kills</span>
|
||||||
|
<span id="profile-kills" class="summary-value"></span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<span class="summary-label">Deaths</span>
|
||||||
|
<span id="profile-deaths" class="summary-value"></span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<span class="summary-label">Kill/Death</span>
|
||||||
|
<span id="profile-kd" class="summary-value"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-body">
|
||||||
|
<!-- Stats column -->
|
||||||
|
<div class="profile-stats">
|
||||||
|
<h3>Attributes</h3>
|
||||||
|
<div id="profile-stats-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Equipment column -->
|
||||||
|
<div class="profile-equipment">
|
||||||
|
<h3>Equipment</h3>
|
||||||
|
<div id="profile-equipment-list" class="profile-equip-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="profile-error" style="display:none;" class="error"></div>
|
||||||
|
<div id="profile-notfound" style="display:none;" class="placeholder">
|
||||||
|
Hero not found. Check the name and try again.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/auth.js"></script>
|
||||||
|
<script src="/js/profile.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// Profile page integration tests: verifies the profile API endpoint is accessible
|
||||||
|
// from the profile page context (public, no auth).
|
||||||
|
const { describe, it, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { setup, teardown, cleanup } = require('../helpers');
|
||||||
|
|
||||||
|
describe('profile page (public)', () => {
|
||||||
|
let baseUrl, userSeq = 0;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
baseUrl = await setup();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await teardown();
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function registerAndLogin(username, password) {
|
||||||
|
await fetch(`${baseUrl}/api/auth/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const setCookie = res.headers.get('set-cookie');
|
||||||
|
assert.ok(setCookie, 'Login should set a session cookie');
|
||||||
|
return setCookie.split(';')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createHero(cookie, heroName, heroClass) {
|
||||||
|
const res = await fetch(`${baseUrl}/api/character`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Cookie: cookie },
|
||||||
|
body: JSON.stringify({ name: heroName, class: heroClass }),
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 201);
|
||||||
|
return jsonSafe(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function jsonSafe(res) {
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHeroField(characterId, column, value) {
|
||||||
|
const db = require('../../src/db').getDb();
|
||||||
|
db.prepare(`UPDATE characters SET ${column} = ? WHERE id = ?`).run(value, characterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('page loads and profile API returns correct data for a warrior', async () => {
|
||||||
|
const cookie = await registerAndLogin(`profuser1`, 'password123');
|
||||||
|
await createHero(cookie, 'ProfileWarrior', 'warrior');
|
||||||
|
updateHeroField(
|
||||||
|
require('../../src/db').getDb().prepare('SELECT id FROM characters WHERE name = ?').get('ProfileWarrior').id,
|
||||||
|
'level', 7
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/heroes/ProfileWarrior`);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const json = await res.json();
|
||||||
|
assert.equal(json.ok, true);
|
||||||
|
assert.equal(json.data.name, 'ProfileWarrior');
|
||||||
|
assert.equal(json.data.class, 'warrior');
|
||||||
|
assert.equal(json.data.level, 7);
|
||||||
|
assert.ok('str' in json.data);
|
||||||
|
assert.ok('agi' in json.data);
|
||||||
|
assert.ok('int' in json.data);
|
||||||
|
assert.ok('vit' in json.data);
|
||||||
|
assert.ok('equipment' in json.data);
|
||||||
|
assert.ok('weapon' in json.data.equipment);
|
||||||
|
assert.ok('armor' in json.data.equipment);
|
||||||
|
assert.ok('trinket' in json.data.equipment);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('page loads and profile API returns correct data for a mage', async () => {
|
||||||
|
const cookie = await registerAndLogin(`profuser2`, 'password123');
|
||||||
|
await createHero(cookie, 'ProfileMage', 'mage');
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/heroes/ProfileMage`);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const json = await res.json();
|
||||||
|
assert.equal(json.ok, true);
|
||||||
|
assert.equal(json.data.name, 'ProfileMage');
|
||||||
|
assert.equal(json.data.class, 'mage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('page loads and profile API returns correct data for a ranger', async () => {
|
||||||
|
const cookie = await registerAndLogin(`profuser3`, 'password123');
|
||||||
|
await createHero(cookie, 'ProfileRanger', 'ranger');
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/heroes/ProfileRanger`);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const json = await res.json();
|
||||||
|
assert.equal(json.ok, true);
|
||||||
|
assert.equal(json.data.name, 'ProfileRanger');
|
||||||
|
assert.equal(json.data.class, 'ranger');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for non-existent hero', async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/heroes/DoesNotExist12345`);
|
||||||
|
assert.equal(res.status, 404);
|
||||||
|
const json = await res.json();
|
||||||
|
assert.equal(json.ok, false);
|
||||||
|
assert.equal(json.error.code, 'NOT_FOUND');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hero names with special chars are URL-safe', async () => {
|
||||||
|
const cookie = await registerAndLogin(`profuser4`, 'password123');
|
||||||
|
await createHero(cookie, "O'Brien", 'warrior');
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/heroes/${encodeURIComponent("O'Brien")}`);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const json = await res.json();
|
||||||
|
assert.equal(json.ok, true);
|
||||||
|
assert.equal(json.data.name, "O'Brien");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user