spoint 0.1.644 → 0.1.646

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,190 @@
1
+ import { findSpawnPoints, getAvailableSpawnPoint, handleFire, loadScoreboard, flushScoreboard, persistPlayerStat } from './server.js'
2
+ import { collectSpawnPoints } from '../spawn-point/index.js'
3
+ import { POWERUP_DEFS, POWERUP_RESPAWN_MS, POWERUP_PICKUP_RADIUS, EMOTE_CLIPS, spawnPowerup } from './shared.js'
4
+
5
+ export const tpsGameServer = {
6
+ async setup(ctx) {
7
+ ctx.state.map = 'schwust'
8
+ ctx.state.mode = 'ffa'
9
+ ctx.state.config = { respawnTime: 1.5, health: 100, damagePerHit: 20, headshotMultiplier: 2.5, headshotZone: 0.7, hitKnockback: 4, shootKnockback: 2, magazineSize: 30, reloadTime: 2000, spawnInvulnMs: 1500 }
10
+ ctx.state.invuln = new Map()
11
+ // Placed spawn-point entities (apps/spawn-point) take priority over the raycast grid --
12
+ // a maker who drops markers gets exactly those; the grid is only the no-markers-placed
13
+ // fallback so existing worlds without markers keep working unchanged.
14
+ const placedSpawns = collectSpawnPoints(ctx)
15
+ ctx.state.spawnPoints = placedSpawns.length > 0 ? placedSpawns : findSpawnPoints(ctx)
16
+ ctx.state.playerStats = new Map()
17
+ // Cumulative session/across-restart scoreboard, keyed by durable player name -- see server.js
18
+ // loadScoreboard/persistPlayerStat. Awaited here (AppRuntime awaits server.setup) so it is fully
19
+ // populated before the FIRST player_join can look a name up.
20
+ await loadScoreboard(ctx)
21
+ // Register the debounced scoreboard write's flush with the engine's graceful-shutdown registry
22
+ // (ctx.onShutdown, src/apps/AppContext.js/AppRuntime.js) so a SIGINT/SIGTERM within the 500ms
23
+ // debounce window (see scheduleScoreboardPersist in server.js) doesn't silently drop the last
24
+ // burst of kill/death stat changes -- mirrors ctx.placedModelStorage.flush()'s own shutdown wiring.
25
+ ctx.onShutdown(() => flushScoreboard(ctx))
26
+ ctx.state.respawning = new Map()
27
+ ctx.state.buffs = new Map()
28
+ ctx.state.ammo = new Map()
29
+ ctx.state.reloading = new Map()
30
+ ctx.state.lastEmoteAt = new Map()
31
+ ctx.state.started = Date.now()
32
+ ctx.state.gameTime = 0
33
+ ctx.state.fallTimers = new Map()
34
+ ctx.state.killStreaks = new Map()
35
+ ctx.state.powerups = new Map()
36
+ const sps = ctx.state.spawnPoints
37
+ const picks = (sps && sps.length >= POWERUP_DEFS.length)
38
+ ? POWERUP_DEFS.map((_, i) => sps[Math.floor((i + 1) * sps.length / (POWERUP_DEFS.length + 1))])
39
+ : POWERUP_DEFS.map((_, i) => [i * 8 - 8, 3, 0])
40
+ POWERUP_DEFS.forEach((def, i) => {
41
+ const p = picks[i], pos = [p[0], p[1] + 0.6, p[2]], id = `powerup_${def.type}`
42
+ ctx.state.powerups.set(id, { def, position: pos, active: true, respawnAt: 0 })
43
+ spawnPowerup(ctx, id, def, pos)
44
+ })
45
+ ctx.bus.on('powerup.collected', (event) => {
46
+ const d = event.data
47
+ ctx.state.buffs.set(d.playerId, { expiresAt: Date.now() + d.duration * 1000, speed: d.speedMultiplier, fireRate: d.fireRateMultiplier, damage: d.damageMultiplier })
48
+ ctx.players.send(d.playerId, { type: 'buff_applied', duration: d.duration, speed: d.speedMultiplier, fireRate: d.fireRateMultiplier, damage: d.damageMultiplier })
49
+ })
50
+ console.log(`[tps-game] ${ctx.state.spawnPoints.length} spawn points validated`)
51
+ },
52
+
53
+ update(ctx, dt) {
54
+ ctx.state.gameTime = (Date.now() - ctx.state.started) / 1000
55
+ const now = Date.now()
56
+ // Defensive re-init: setup() constructs ctx.state.buffs as a real Map. The two real root causes
57
+ // that could hand back a non-Map here are both fixed upstream now: (1) the init-order race, where
58
+ // update() could run before setup()'s async loadScoreboard await resolves -- see AppRuntime.js's
59
+ // _pendingSetupIds skip in _rebuildUpdateList/_rebuildCollisionList; (2) a Map silently downgrading
60
+ // to a plain object across any restoreGameState/WorldPersistence round-trip, since a naive
61
+ // JSON.parse(JSON.stringify(...)) has no Map wire type -- fixed via AppRuntime.js's tagged
62
+ // cloneAppState (Map/Set-preserving replacer/reviver), used for entity._appState (ctx.state)
63
+ // specifically. This guard stays as cheap, top-of-update defense-in-depth against any future
64
+ // write path this loop hasn't been audited against yet, not because either known cause is still open.
65
+ if (!(ctx.state.buffs instanceof Map)) ctx.state.buffs = new Map()
66
+ for (const [pid, buff] of ctx.state.buffs) {
67
+ if (now >= buff.expiresAt) { ctx.state.buffs.delete(pid); ctx.players.send(pid, { type: 'buff_expired' }) }
68
+ else { const player = ctx.players.getById(pid); if (player?.state) player.state.health = Math.min(ctx.state.config.health, (player.state.health ?? ctx.state.config.health) + (ctx.state.config.health / 10) * dt) }
69
+ }
70
+ const allPlayers = ctx.players.getAll()
71
+ for (const player of allPlayers) {
72
+ if (!player.state || ctx.state.respawning.has(player.id)) continue
73
+ if ((player.state.health ?? ctx.state.config.health) <= 0) continue
74
+ const y = player.state.position?.[1] ?? 0
75
+ if (y < -20) {
76
+ const t = (ctx.state.fallTimers.get(player.id) || 0) + dt
77
+ ctx.state.fallTimers.set(player.id, t)
78
+ if (t >= 0.5) { player.state.health = 0; ctx.state.respawning.set(player.id, { respawnAt: now + ctx.state.config.respawnTime * 1000, killer: null }); ctx.network.broadcast({ type: 'death', victim: player.id, killer: null, cause: 'fall' }); ctx.state.fallTimers.delete(player.id) }
79
+ } else { ctx.state.fallTimers.delete(player.id) }
80
+ }
81
+ // Same defensive re-init as ctx.state.buffs above (init-order race / Map->plain-object downgrade
82
+ // across a restoreGameState/WorldPersistence round-trip) -- powerups hit the identical hazard since
83
+ // it is also a Map constructed once in setup() with no per-tick type guard until now.
84
+ if (!(ctx.state.powerups instanceof Map)) ctx.state.powerups = new Map()
85
+ {
86
+ for (const [id, pu] of ctx.state.powerups) {
87
+ if (pu.active) {
88
+ for (const player of allPlayers) {
89
+ if (!player.state || ctx.state.respawning.has(player.id)) continue
90
+ if ((player.state.health ?? ctx.state.config.health) <= 0) continue
91
+ const pp = player.state.position; if (!pp) continue
92
+ const dx = pp[0] - pu.position[0], dy = pp[1] - pu.position[1], dz = pp[2] - pu.position[2]
93
+ if (dx * dx + dy * dy + dz * dz <= POWERUP_PICKUP_RADIUS * POWERUP_PICKUP_RADIUS) {
94
+ ctx.bus.emit('powerup.collected', { playerId: player.id, duration: pu.def.buff.duration, speedMultiplier: pu.def.buff.speedMultiplier, fireRateMultiplier: pu.def.buff.fireRateMultiplier, damageMultiplier: pu.def.buff.damageMultiplier })
95
+ ctx.world.destroy(id)
96
+ pu.active = false; pu.respawnAt = now + POWERUP_RESPAWN_MS
97
+ break
98
+ }
99
+ }
100
+ } else if (now >= pu.respawnAt) {
101
+ spawnPowerup(ctx, id, pu.def, pu.position); pu.active = true
102
+ }
103
+ }
104
+ }
105
+ for (const [pid, data] of ctx.state.respawning) {
106
+ if (now < data.respawnAt) continue
107
+ const sp = getAvailableSpawnPoint(ctx, ctx.state.spawnPoints)
108
+ const player = ctx.players.getById(pid)
109
+ if (player?.state) { player.state.health = ctx.state.config.health; player.state.velocity = [0, 0, 0]; ctx.players.setPosition(pid, sp) }
110
+ ctx.state.invuln.set(pid, now + (ctx.state.config.spawnInvulnMs || 0))
111
+ // respawn must reset ammo/reload same as player_join, else stale magazine silently rejects every shot client thinks it has
112
+ ctx.state.ammo.set(pid, ctx.state.config.magazineSize)
113
+ ctx.state.reloading.delete(pid)
114
+ ctx.players.send(pid, { type: 'respawn', position: sp, health: ctx.state.config.health, ammo: ctx.state.config.magazineSize, invulnMs: ctx.state.config.spawnInvulnMs })
115
+ ctx.state.respawning.delete(pid)
116
+ }
117
+ },
118
+
119
+ onMessage(ctx, msg) {
120
+ if (!msg) return
121
+ if (msg.type === 'player_join') {
122
+ const p = ctx.players.getById(msg.playerId)
123
+ // must not force health on reconnect: RECONNECT_ACK already restored it, forcing max would res a mid-blip death
124
+ if (p?.state && !msg.reconnected) p.state.health = ctx.state.config.health
125
+ if (!msg.reconnected || !ctx.state.playerStats.has(msg.playerId)) {
126
+ // Restore cumulative kills/deaths/damage from the durable by-name scoreboard (see server.js
127
+ // loadScoreboard/persistPlayerStat) if this player's name has a saved record -- a fresh Map
128
+ // entry every join/reconnect used to silently reset the live in-memory stats to zero even
129
+ // though the durable record on disk still had the player's real cumulative totals.
130
+ const name = p?.name || `Player ${msg.playerId}`
131
+ const saved = ctx.state.scoreboardByName?.[name]
132
+ ctx.state.playerStats.set(msg.playerId, saved ? { kills: saved.kills || 0, deaths: saved.deaths || 0, damage: saved.damage || 0 } : { kills: 0, deaths: 0, damage: 0 })
133
+ }
134
+ ctx.state.ammo.set(msg.playerId, ctx.state.config.magazineSize)
135
+ ctx.state.reloading.delete(msg.playerId)
136
+ }
137
+ if (msg.type === 'player_leave') {
138
+ // Final mirror-and-persist BEFORE dropping the in-memory entry -- covers the case where the
139
+ // last stat change since the previous debounce fired (e.g. a damage tick from the shot that
140
+ // killed the leaving player) hasn't hit disk yet. The durable by-name record is what survives;
141
+ // the in-memory playerStats Map is keyed by this ephemeral playerId and is safe to drop, since
142
+ // a future rejoin re-seeds from ctx.state.scoreboardByName (by name) on player_join above.
143
+ persistPlayerStat(ctx, msg.playerId)
144
+ ctx.state.playerStats.delete(msg.playerId); ctx.state.respawning.delete(msg.playerId)
145
+ ctx.state.fallTimers.delete(msg.playerId); ctx.state.ammo.delete(msg.playerId); ctx.state.reloading.delete(msg.playerId); ctx.state.invuln.delete(msg.playerId)
146
+ }
147
+ if (msg.type === 'reload') {
148
+ const playerId = msg.senderId || msg.playerId
149
+ if (ctx.state.reloading.has(playerId) || (ctx.state.ammo.get(playerId) ?? 0) >= ctx.state.config.magazineSize) return
150
+ ctx.state.reloading.set(playerId, { startTime: Date.now() })
151
+ ctx.players.send(playerId, { type: 'reload_start', duration: ctx.state.config.reloadTime })
152
+ setTimeout(() => { ctx.state.ammo.set(playerId, ctx.state.config.magazineSize); ctx.state.reloading.delete(playerId); ctx.players.send(playerId, { type: 'reload_complete' }) }, ctx.state.config.reloadTime)
153
+ }
154
+ if (msg.type === 'emote') {
155
+ // Server-authoritative allowlist: never trust a client-supplied clip name directly into
156
+ // playAnimation (an arbitrary string reaching the animation library lookup is low-risk here
157
+ // since it only no-ops on a miss, but an explicit allowlist is the correct discipline for any
158
+ // client-triggered broadcast -- matches roadmap #78's own 'networked emote codes' framing,
159
+ // a CODE the client sends, not a free-text clip name). Rate-limited per player (reuses the
160
+ // same reload-style timestamp-gate pattern as the fire/reload handlers above) so a client
161
+ // can't spam a broadcast to every other connected player.
162
+ const playerId = msg.senderId || msg.playerId
163
+ const now = Date.now()
164
+ const lastEmote = ctx.state.lastEmoteAt.get(playerId) || 0
165
+ if (now - lastEmote < 800) return
166
+ if (!EMOTE_CLIPS.has(msg.code)) return
167
+ ctx.state.lastEmoteAt.set(playerId, now)
168
+ ctx.players.playAnimation(playerId, EMOTE_CLIPS.get(msg.code), { loop: false })
169
+ }
170
+ if (msg.type === 'fire') {
171
+ const shooterId = msg.senderId || msg.shooterId
172
+ if (ctx.state.reloading.has(shooterId)) return
173
+ const ammo = ctx.state.ammo.get(shooterId) ?? 0
174
+ if (ammo <= 0) { ctx.players.send(shooterId, { type: 'empty_click' }); return }
175
+ ctx.state.ammo.set(shooterId, ammo - 1)
176
+ const shooter = ctx.players.getById(shooterId)
177
+ const pos = shooter?.state?.position || [0, 0, 0]
178
+ const origin = [pos[0], pos[1] + 0.9, pos[2]]
179
+ // msg.clientTime is expressed in ESTIMATED SERVER CLOCK time (BaseClient.sendFire adds the
180
+ // client's NTP-style clock offset before sending), so Date.now()-msg.clientTime here is a real
181
+ // one-way client->server delay estimate, not the old raw-clock-skew-conflated value.
182
+ const latencyMs = msg.clientTime ? Math.min(600, Math.max(0, Date.now() - msg.clientTime)) : 0
183
+ const fireData = { shooterId, origin, direction: msg.direction, latencyMs }
184
+ ctx.bus.emit('combat.fire', fireData)
185
+ if (shooter?.state) { shooter.state.velocity[0] -= msg.direction[0] * ctx.state.config.shootKnockback; shooter.state.velocity[2] -= msg.direction[2] * ctx.state.config.shootKnockback }
186
+ ctx.players.send(shooterId, { type: 'aimpunch', intensity: 0.3 })
187
+ handleFire(ctx, fireData)
188
+ }
189
+ }
190
+ }
@@ -0,0 +1,60 @@
1
+ // Constants and pure helpers shared between apps/tps-game/index.js's server and client app halves.
2
+
3
+ export const POWERUP_DEFS = [
4
+ { type: 'damage', color: 0xff3344, emissive: 0xaa0000, buff: { duration: 20, speedMultiplier: 1, fireRateMultiplier: 1, damageMultiplier: 2 } },
5
+ { type: 'speed', color: 0x33aaff, emissive: 0x0044aa, buff: { duration: 20, speedMultiplier: 1.5, fireRateMultiplier: 1, damageMultiplier: 1 } },
6
+ { type: 'rapid', color: 0xffcc33, emissive: 0xaa6600, buff: { duration: 20, speedMultiplier: 1, fireRateMultiplier: 2, damageMultiplier: 1 } },
7
+ ]
8
+ export const POWERUP_RESPAWN_MS = 15000
9
+ export const POWERUP_PICKUP_RADIUS = 1.7
10
+
11
+ // Emote wheel (roadmap #78): a short networked CODE (not a free-text clip name -- server allowlist
12
+ // in server.js), mapped to a real clip confirmed present in client/anim-lib.glb (109 real clips inspected
13
+ // live via gltf-transform, not guessed). Order here is the wheel's slot 1..8 (client/hud/EmoteWheel.js
14
+ // lays slots out clockwise from the top in this same array order).
15
+ export const EMOTE_CLIPS = new Map([
16
+ ['wave', 'Bow'],
17
+ ['dance', 'DanceLoop'],
18
+ ['nod', 'HeadNod'],
19
+ ['victory', 'Victory'],
20
+ ['meditate', 'Meditate'],
21
+ ['jumpingjacks', 'JumpingJacks'],
22
+ ['confused', 'Confused'],
23
+ ['sit', 'SittingEnter'],
24
+ ])
25
+ export const EMOTE_WHEEL_SLOTS = [
26
+ { code: 'wave', label: 'Bow' },
27
+ { code: 'dance', label: 'Dance' },
28
+ { code: 'nod', label: 'Nod' },
29
+ { code: 'victory', label: 'Victory' },
30
+ { code: 'meditate', label: 'Meditate' },
31
+ { code: 'jumpingjacks', label: 'Jumping Jacks' },
32
+ { code: 'confused', label: 'Confused' },
33
+ { code: 'sit', label: 'Sit' },
34
+ ]
35
+
36
+ export function spawnPowerup(ctx, id, def, position) {
37
+ ctx.world.spawn(id, {
38
+ position: [...position], scale: [0.55, 0.55, 0.55],
39
+ custom: { mesh: 'box', powerup: def.type, color: def.color, emissive: def.emissive, emissiveIntensity: 0.7, light: def.color, lightIntensity: 0.9, lightRange: 6, spin: 1.6, hover: 0.35 }
40
+ })
41
+ }
42
+
43
+ // mirrors server.js handleFire's hit geometry (capsule +0.9, radius 0.6) for client-side prediction; server stays authoritative
44
+ export function predictHit(origin, dir, players, selfId, headshotZone) {
45
+ if (!origin || !dir || !players) return null
46
+ for (const p of players) {
47
+ if (!p || p.id === selfId || !p.position) continue
48
+ if ((p.health ?? 100) <= 0) continue
49
+ const tp = p.position
50
+ const toX = tp[0] - origin[0], toY = tp[1] + 0.9 - origin[1], toZ = tp[2] - origin[2]
51
+ const dot = toX * dir[0] + toY * dir[1] + toZ * dir[2]
52
+ if (dot < 0 || dot > 1000) continue
53
+ const px = origin[0] + dir[0] * dot, py = origin[1] + dir[1] * dot, pz = origin[2] + dir[2] * dot
54
+ const ddx = px - tp[0], ddy = py - (tp[1] + 0.9), ddz = pz - tp[2]
55
+ const d2 = ddx * ddx + ddy * ddy + ddz * ddz
56
+ if (d2 > 0.36) continue
57
+ return { headshot: ((py - tp[1]) / 1.8) >= (headshotZone ?? 0.7) }
58
+ }
59
+ return null
60
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spoint",
3
- "version": "0.1.644",
3
+ "version": "0.1.646",
4
4
  "description": "Physics and netcode SDK for multiplayer game servers",
5
5
  "type": "module",
6
6
  "workspaces": [