Use socket to manage loksyu

This commit is contained in:
2026-06-14 22:54:37 +02:00
parent 4cb8e26333
commit 50038a13f9
68 changed files with 464 additions and 233 deletions
+15 -1
View File
@@ -43,6 +43,9 @@ export class CDELoksyuApp extends foundry.applications.api.HandlebarsApplication
/** @type {Function|null} bound hook handler */
_updateHook = null
/** @type {Function|null} updateSetting hook handler (for socket-propagated writes) */
_settingHook = null
/** Singleton accessor — open or bring to front */
static open() {
const existing = Array.from(foundry.applications.instances.values()).find(
@@ -78,7 +81,14 @@ export class CDELoksyuApp extends foundry.applications.api.HandlebarsApplication
super._onRender(context, options)
this.#bindInputs()
this._updateHook = Hooks.on("cde:loksyuUpdated", () => this.render())
if (!this._updateHook) {
this._updateHook = Hooks.on("cde:loksyuUpdated", () => this.render())
}
if (!this._settingHook) {
this._settingHook = Hooks.on("updateSetting", (setting) => {
if (setting.key === `${SYSTEM_ID}.loksyuData`) this.render()
})
}
}
_onClose(options) {
@@ -86,6 +96,10 @@ export class CDELoksyuApp extends foundry.applications.api.HandlebarsApplication
Hooks.off("cde:loksyuUpdated", this._updateHook)
this._updateHook = null
}
if (this._settingHook !== null) {
Hooks.off("updateSetting", this._settingHook)
this._settingHook = null
}
super._onClose(options)
}
+110 -13
View File
@@ -15,10 +15,14 @@
* Loksyu / TinJi settings-based helpers.
*
* Data is stored as world settings instead of singleton Actor documents.
* Socket-based replication allows non-GM players to update Loksyu and
* TinJi — the GM processes the actual writes.
*/
import { SYSTEM_ID, WU_XING_CYCLE, ASPECT_FACES } from "../../config/constants.js"
const SOCKET_CHANNEL = `system.${SYSTEM_ID}`
/** Read the current loksyu data object from world settings */
export function getLoksyuData() {
return game.settings.get(SYSTEM_ID, "loksyuData") ?? {
@@ -26,27 +30,112 @@ export function getLoksyuData() {
}
}
/** Write the loksyu data object to world settings */
export async function setLoksyuData(data) {
/** Write the loksyu data object to world settings (GM only). */
async function writeLoksyuData(data) {
await game.settings.set(SYSTEM_ID, "loksyuData", data)
Hooks.callAll("cde:loksyuUpdated", data)
}
/** Write TinJi value to world settings (GM only). */
async function writeTinjiValue(value) {
value = Math.max(0, value)
await game.settings.set(SYSTEM_ID, "tinjiData", value)
Hooks.callAll("cde:tinjiUpdated", value)
}
/** Write the loksyu data object — non-GM emits via socket. */
export async function setLoksyuData(data) {
if (game.user.isGM) return writeLoksyuData(data)
game.socket.emit(SOCKET_CHANNEL, { action: "setLoksyuData", data })
}
/** Read current TinJi value from world settings */
export function getTinjiValue() {
return game.settings.get(SYSTEM_ID, "tinjiData") ?? 0
}
/** Write TinJi value to world settings */
/** Write TinJi value — non-GM emits via socket. */
export async function setTinjiValue(value) {
await game.settings.set(SYSTEM_ID, "tinjiData", Math.max(0, value))
Hooks.callAll("cde:tinjiUpdated", Math.max(0, value))
if (game.user.isGM) return writeTinjiValue(value)
game.socket.emit(SOCKET_CHANNEL, { action: "setTinjiValue", value })
}
/**
* Non-GM: request an atomic Loksyu die draw via socket. The GM reads
* current state, decrements, and writes — avoiding stale-read races.
*/
export function requestLoksyuDraw(aspect, order) {
game.socket.emit(SOCKET_CHANNEL, { action: "loksyuDraw", aspect, order })
}
/**
* Non-GM: request an atomic TinJi spend via socket. The GM reads current
* value, decrements if > 0, and writes.
*/
export function requestTinjiSpend() {
game.socket.emit(SOCKET_CHANNEL, { action: "tinjiSpend" })
}
/**
* Register the socket listener that processes Loksyu/TinJi write requests
* from non-GM clients. Only the GM actually performs the writes; other
* clients ignore the message.
*
* Must be called after the `ready` hook (when game.socket is available).
*/
export function registerSingletonSocket() {
game.socket.on(SOCKET_CHANNEL, async (payload) => {
if (!game.user.isGM) return
switch (payload.action) {
case "setLoksyuData":
await writeLoksyuData(payload.data)
break
case "setTinjiValue":
await writeTinjiValue(payload.value)
break
case "updateLoksyuFromRoll":
await updateLoksyuFromRoll(payload.activeAspect, payload.faces)
break
case "updateTinjiFromRoll":
await updateTinjiFromRoll(payload.delta)
break
case "loksyuDraw": {
const data = getLoksyuData()
const entry = data[payload.aspect] ?? { yin: 0, yang: 0 }
const order = payload.order ?? "yang-first"
if (order === "yin-first") {
if (entry.yin > 0) entry.yin--
else entry.yang--
} else if (order === "balanced") {
if (entry.yin > entry.yang) entry.yin--
else if (entry.yang > entry.yin) entry.yang--
else if (entry.yang > 0) entry.yang--
else entry.yin--
} else {
if (entry.yang > 0) entry.yang--
else entry.yin--
}
data[payload.aspect] = entry
await writeLoksyuData(data)
break
}
case "tinjiSpend": {
const cur = getTinjiValue()
if (cur > 0) await writeTinjiValue(cur - 1)
break
}
}
})
}
/**
* After a WuXing roll, add the loksyu faces (yin + yang) of the relevant
* aspect to the loksyu settings data.
*
* Non-GM: emits raw activeAspect+faces via socket so the GM recomputes
* from current state — avoids stale-read races when two players roll
* simultaneously.
*
* @param {string} activeAspect - e.g. "fire"
* @param {Object} faces - Die face counts { 0: n, 1: n, …, 9: n }
*/
@@ -62,22 +151,30 @@ export async function updateLoksyuFromRoll(activeAspect, faces) {
const yangCount = faces[yangFace] ?? 0
if (yinCount === 0 && yangCount === 0) return
const data = getLoksyuData()
const current = data[lokAspect] ?? { yin: 0, yang: 0 }
data[lokAspect] = {
yin: (current.yin ?? 0) + yinCount,
yang: (current.yang ?? 0) + yangCount,
if (game.user.isGM) {
const data = getLoksyuData()
const current = data[lokAspect] ?? { yin: 0, yang: 0 }
data[lokAspect] = { yin: (current.yin ?? 0) + yinCount, yang: (current.yang ?? 0) + yangCount }
await writeLoksyuData(data)
} else {
game.socket.emit(SOCKET_CHANNEL, { action: "updateLoksyuFromRoll", activeAspect, faces })
}
await setLoksyuData(data)
}
/**
* After a WuXing roll, add tinji faces to the TinJi settings.
*
* Non-GM: emits delta via socket so the GM adds to the current value
* atomically.
*
* @param {number} count - Number of tinji faces rolled
*/
export async function updateTinjiFromRoll(count) {
if (!count || count <= 0) return
const current = getTinjiValue()
await setTinjiValue(current + count)
if (game.user.isGM) {
const current = getTinjiValue()
await writeTinjiValue(current + count)
} else {
game.socket.emit(SOCKET_CHANNEL, { action: "updateTinjiFromRoll", delta: count })
}
}
+15 -1
View File
@@ -44,6 +44,9 @@ export class CDETinjiApp extends foundry.applications.api.HandlebarsApplicationM
/** @type {Function|null} */
_updateHook = null
/** @type {Function|null} */
_settingHook = null
static open() {
const existing = Array.from(foundry.applications.instances.values()).find(
(app) => app instanceof CDETinjiApp
@@ -64,7 +67,14 @@ export class CDETinjiApp extends foundry.applications.api.HandlebarsApplicationM
_onRender(context, options) {
super._onRender(context, options)
this.#bindDirectInput()
this._updateHook = Hooks.on("cde:tinjiUpdated", () => this.render())
if (!this._updateHook) {
this._updateHook = Hooks.on("cde:tinjiUpdated", () => this.render())
}
if (!this._settingHook) {
this._settingHook = Hooks.on("updateSetting", (setting) => {
if (setting.key === `${SYSTEM_ID}.tinjiData`) this.render()
})
}
}
_onClose(options) {
@@ -72,6 +82,10 @@ export class CDETinjiApp extends foundry.applications.api.HandlebarsApplicationM
Hooks.off("cde:tinjiUpdated", this._updateHook)
this._updateHook = null
}
if (this._settingHook !== null) {
Hooks.off("updateSetting", this._settingHook)
this._settingHook = null
}
super._onClose(options)
}
+13 -7
View File
@@ -20,7 +20,7 @@
* with the new counts, without creating noise.
*/
import { getLoksyuData, setLoksyuData, getTinjiValue, setTinjiValue } from "./apps/singletons.js"
import { getLoksyuData, setLoksyuData, getTinjiValue, setTinjiValue, requestLoksyuDraw, requestTinjiSpend } from "./apps/singletons.js"
import { SYSTEM_ID, WU_XING_CYCLE, ASPECT_LABELS, ASPECT_ICONS } from "../config/constants.js"
const RESULT_TEMPLATE = "systems/fvtt-chroniques-de-l-etrange/templates/form/cde-dice-result.html"
@@ -53,9 +53,8 @@ function refreshRollActions(rollCard, aspect, message) {
const successAvail = (loksyu[aspect]?.yin ?? 0) + (loksyu[aspect]?.yang ?? 0)
const fasteAvail = (loksyu[fasteAspect]?.yin ?? 0) + (loksyu[fasteAspect]?.yang ?? 0)
const isGM = game.user.isGM
const hasSomething = successAvail > 0 || fasteAvail > 0 || (isGM && tinji > 0)
const hasSomething = successAvail > 0 || fasteAvail > 0 || tinji > 0
if (!hasSomething) return
const aspLabel = game.i18n.localize(ASPECT_LABELS[aspect])
@@ -79,7 +78,7 @@ function refreshRollActions(rollCard, aspect, message) {
</button>`
}
if (isGM && tinji > 0) {
if (tinji > 0) {
btns += `<button class="cde-roll-action-btn cde-roll-action--tinji" data-action="tinji">
<span class="cde-roll-action-tinji-char">天</span>
<span class="cde-roll-action-label">${game.i18n.localize("CDE.TinJi2")}</span>
@@ -149,7 +148,11 @@ async function _drawFromLoksyu(message, aspect, type, aspectLabel) {
else entry.yin--
}
data[aspect] = entry
await setLoksyuData(data)
if (game.user.isGM) {
await setLoksyuData(data)
} else {
requestLoksyuDraw(aspect, order)
}
// Update the roll-result message in-place if it has stored flags
const flags = message?.flags?.[SYSTEM_ID]
@@ -200,13 +203,16 @@ async function _drawFromLoksyu(message, aspect, type, aspectLabel) {
* Spend 1 Tin Ji point (GM only) and post a notification.
*/
async function _spendTinjiPostRoll() {
if (!game.user.isGM) return
const current = getTinjiValue()
if (current <= 0) {
ui.notifications.warn(game.i18n.localize("CDE.TinjiEmpty"))
return
}
await setTinjiValue(current - 1)
if (game.user.isGM) {
await setTinjiValue(current - 1)
} else {
requestTinjiSpend()
}
await ChatMessage.create({
user: game.user.id,
content: `<div class="cde-tinji-spend-msg">